Merge v2.46.0 into develop — resolve all conflicts

- yarn.lock, components.d.ts, ru.po: accepted upstream v2.46.0
- AppSidebar.vue: kept custom sidebar links (MyPoints, LeaderBoard,
  ChatGPT, MyChild, Profile) + adopted async watch from v2.46.0
- MobileLayout.vue: merged onMounted with sidebarSettings.reload +
  custom addSideBar() for role-based mobile links
- EditProfile.vue: adopted new Dialog options structure (size only)
- Courses/Courses.vue: unified tab values to lowercase (enrolled,
  upcoming, new, created, unpublished)
- CourseDetail.vue (old): removed — logic migrated to Courses/CourseDetail.vue;
  transferred custom tag flex-wrap styling to CourseOverview.vue
- LessonForm.vue: kept Rutube video support
- App.vue: clean (no conflict markers)
- user.py: merged imports + kept custom sign_up params (phone, user_role)
- utils.py: kept render_html (Rutube), is_mentor, is_eligible_to_review;
  added type hints for get_course_progress from v2.46.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicolai
2026-03-09 18:25:58 +03:00
430 changed files with 64129 additions and 47136 deletions

View File

@@ -1 +1 @@
__version__ = "2.39.2"
__version__ = "2.46.0"

104
lms/auth.py Normal file
View File

@@ -0,0 +1,104 @@
import json
import frappe
ALLOWED_PATHS = [
"/api/method/ping",
"/api/method/login",
"/api/method/logout",
"/api/method/frappe.core.doctype.communication.email.mark_email_as_seen",
"/api/method/frappe.realtime.get_user_info",
"/api/method/frappe.realtime.can_subscribe_doc",
"/api/method/frappe.realtime.can_subscribe_doctype",
"/api/method/frappe.realtime.has_permission",
"/api/method/frappe.integrations.oauth2.authorize",
"/api/method/frappe.integrations.oauth2.approve",
"/api/method/frappe.integrations.oauth2.get_token",
"/api/method/frappe.www.login.login_via_google",
"/api/method/frappe.www.login.login_via_github",
"/api/method/frappe.www.login.login_via_facebook",
"/api/method/frappe.www.login.login_via_frappe",
"/api/method/frappe.www.login.login_via_office365",
"/api/method/frappe.www.login.login_via_salesforce",
"/api/method/frappe.www.login.login_via_fairlogin",
"/api/method/frappe.www.login.login_via_keycloak",
"/api/method/frappe.www.login.custom",
"/api/method/frappe.integrations.oauth2.openid_profile",
"/api/method/frappe.website.doctype.web_page_view.web_page_view.make_view_log",
"/api/method/upload_file",
"/api/method/frappe.search.web_search",
"/api/method/frappe.email.queue.unsubscribe",
"/api/method/frappe.website.doctype.web_form.web_form.accept",
"/api/method/frappe.core.doctype.user.user.test_password_strength",
"/api/method/frappe.core.doctype.user.user.update_password",
"/api/method/frappe.utils.telemetry.pulse.client.is_enabled",
"/api/method/frappe.client.get_value",
"/api/method/frappe.client.get_count",
"/api/method/frappe.client.get",
"/api/method/frappe.client.insert",
"/api/method/frappe.client.set_value",
"/api/method/frappe.client.delete",
"/api/method/frappe.client.get_list",
"/api/method/frappe.client.rename_doc",
"/api/method/frappe.onboarding.get_onboarding_status",
"/api/method/frappe.utils.print_format.download_pdf",
"/api/method/frappe.desk.search.search_link",
"/api/method/frappe.core.doctype.communication.email.make",
"/api/method/frappe.core.doctype.user.user.reset_password",
"/api/method/frappe.desk.doctype.notification_log.notification_log.mark_as_read",
"/api/method/frappe.desk.doctype.notification_log.notification_log.mark_all_as_read",
"/api/method/frappe.sessions.clear",
]
def authenticate():
if not frappe.conf.get("block_endpoints"):
return
if frappe.form_dict.cmd:
path = f"/api/method/{frappe.form_dict.cmd}"
else:
path = frappe.request.path
user_type = frappe.db.get_value("User", frappe.session.user, "user_type")
if user_type == "System User":
return
if not path.startswith("/api/"):
return
if path.startswith("/lms") or path.startswith("/api/method/lms."):
return
if is_server_script_path(path):
return
if is_custom_app_endpoint(path):
return
if path in ALLOWED_PATHS:
return
frappe.throw(f"Access not allowed for this URL: {path}", frappe.PermissionError)
def is_server_script_path(path):
endpoint = path.split("/api/method/")[-1]
if frappe.db.exists("Server Script", {"script_type": "API", "api_method": endpoint, "disabled": 0}):
return True
return False
def is_custom_app_endpoint(path):
allowed_custom_endpoints = frappe.conf.get("allowed_custom_endpoints", [])
if isinstance(allowed_custom_endpoints, str):
try:
parsed = json.loads(allowed_custom_endpoints)
allowed_custom_endpoints = parsed if isinstance(parsed, list) else [allowed_custom_endpoints]
except Exception:
allowed_custom_endpoints = [allowed_custom_endpoints]
for endpoint in allowed_custom_endpoints:
if endpoint in path:
return True
return False

106
lms/command_palette.py Normal file
View File

@@ -0,0 +1,106 @@
import frappe
from frappe.utils import nowdate
@frappe.whitelist()
def search_sqlite(query: str):
from lms.sqlite import LearningSearch, LearningSearchIndexMissingError
search = LearningSearch()
try:
result = search.search(query)
except LearningSearchIndexMissingError:
return []
return prepare_search_results(result)
def prepare_search_results(result: dict):
groups = get_grouped_results(result)
out = []
for key in groups:
groups[key] = remove_duplicates(groups[key])
groups[key].sort(key=lambda x: x.get("modified"), reverse=True)
out.append({"title": key, "items": groups[key]})
return out
def get_grouped_results(result):
roles = frappe.get_roles()
groups = {}
for r in result["results"]:
doctype = r["doctype"]
if doctype == "LMS Course" and can_access_course(r, roles):
r["author_info"] = get_instructor_info(doctype, r)
groups.setdefault("Courses", []).append(r)
elif doctype == "LMS Batch" and can_access_batch(r, roles):
r["author_info"] = get_instructor_info(doctype, r)
groups.setdefault("Batches", []).append(r)
elif doctype == "Job Opportunity" and can_access_job(r, roles):
r["author_info"] = get_instructor_info(doctype, r)
groups.setdefault("Job Opportunities", []).append(r)
return groups
def remove_duplicates(items):
seen = set()
unique_items = []
for item in items:
if item["name"] not in seen:
seen.add(item["name"])
unique_items.append(item)
return unique_items
def can_access_course(course, roles):
if can_create_course(roles):
return True
elif course.get("published"):
return True
return False
def can_access_batch(batch, roles):
if can_create_batch(roles):
return True
elif batch.get("published") and batch.get("start_date") >= nowdate():
return True
return False
def can_access_job(job, roles):
if "Moderator" in roles:
return True
return job.get("status") == "Open"
def can_create_course(roles):
return "Course Creator" in roles or "Moderator" in roles
def can_create_batch(roles):
return "Batch Evaluator" in roles or "Moderator" in roles
def get_instructor_info(doctype, record):
instructors = frappe.get_all(
"Course Instructor", filters={"parenttype": doctype, "parent": record.get("name")}, pluck="instructor"
)
instructor = record.get("author")
if len(instructors):
for ins in instructors:
if ins.split("@")[0] in record.get("content"):
instructor = ins
break
if not instructor:
instructor = instructors[0]
return frappe.db.get_value(
"User",
instructor,
["full_name", "email", "user_image", "username"],
as_dict=True,
)

View File

@@ -0,0 +1,20 @@
{
"app": "lms",
"creation": "2025-12-15 14:31:50.704854",
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon_type": "App",
"idx": 0,
"label": "Frappe Learning",
"link": "/lms",
"link_type": "External",
"logo_url": "/assets/lms/frontend/learning.svg",
"modified": "2025-12-15 14:31:50.704854",
"modified_by": "Administrator",
"name": "Frappe Learning",
"owner": "Administrator",
"restrict_removal": 0,
"roles": [],
"standard": 1
}

View File

@@ -238,8 +238,8 @@
"dt": "User",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "looking_for_job",
"fieldtype": "Check",
"fieldname": "open_to",
"fieldtype": "Select",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
@@ -250,19 +250,19 @@
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "interest",
"insert_after": "verify_terms",
"is_system_generated": 1,
"is_virtual": 0,
"label": "I am looking for a job",
"label": "Open to",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
"modified": "2021-12-31 12:56:32.110405",
"modified": "2025-12-24 12:56:32.110406",
"module": null,
"name": "User-looking_for_job",
"name": "User-open_to",
"no_copy": 0,
"non_negative": 0,
"options": null,
"options": "\nWork\nHiring",
"permlevel": 0,
"precision": "",
"print_hide": 0,
@@ -406,7 +406,7 @@
"dt": "User",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "medium",
"fieldname": "twitter",
"fieldtype": "Data",
"hidden": 0,
"hide_border": 0,
@@ -421,6 +421,62 @@
"insert_after": "github",
"is_system_generated": 1,
"is_virtual": 0,
"label": "Twitter ID",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
"modified": "2025-12-15 14:46:55.834145",
"module": null,
"name": "User-twitter",
"no_copy": 0,
"non_negative": 0,
"options": null,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
"translatable": 1,
"unique": 0,
"width": null
},
{
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"docstatus": 0,
"doctype": "Custom Field",
"dt": "User",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "medium",
"fieldtype": "Data",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "twitter",
"is_system_generated": 1,
"is_virtual": 0,
"label": "Medium ID",
"length": 0,
"link_filters": null,

View File

@@ -1,16 +1,24 @@
import frappe
from . import __version__ as app_version
app_name = "frappe_lms"
app_title = "Frappe LMS"
app_title = "Learning"
app_publisher = "Frappe"
app_description = "Frappe LMS App"
app_description = "Open Source Learning Management System built with Frappe Framework"
app_icon_url = "/assets/lms/images/lms-logo.png"
app_icon_title = "Learning"
app_icon_route = "/lms"
app_color = "grey"
app_email = "jannat@frappe.io"
app_license = "AGPL"
def get_lms_path():
return (frappe.conf.get("lms_path") or "lms").strip("/")
app_icon_route = f"/{get_lms_path()}"
# Includes in <head>
# ------------------
@@ -64,6 +72,9 @@ after_install = "lms.install.after_install"
after_sync = "lms.install.after_sync"
before_uninstall = "lms.install.before_uninstall"
setup_wizard_requires = "assets/lms/js/setup_wizard.js"
after_migrate = [
"lms.sqlite.build_index_in_background",
]
# Desk Notifications
# ------------------
@@ -75,13 +86,16 @@ setup_wizard_requires = "assets/lms/js/setup_wizard.js"
# -----------
# Permissions evaluated in scripted ways
# permission_query_conditions = {
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
# }
#
# has_permission = {
# "Event": "frappe.desk.doctype.event.event.has_permission",
# }
permission_query_conditions = {
"LMS Certificate": "lms.lms.doctype.lms_certificate.lms_certificate.get_permission_query_conditions",
}
has_permission = {
"LMS Live Class": "lms.lms.doctype.lms_live_class.lms_live_class.has_permission",
"LMS Batch": "lms.lms.doctype.lms_batch.lms_batch.has_permission",
"LMS Program": "lms.lms.doctype.lms_program.lms_program.has_permission",
"LMS Certificate": "lms.lms.doctype.lms_certificate.lms_certificate.has_permission",
}
# DocType Class
# ---------------
@@ -101,7 +115,10 @@ doc_events = {
"lms.lms.doctype.lms_badge.lms_badge.process_badges",
]
},
"Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"},
"Discussion Reply": {
"after_insert": "lms.lms.utils.handle_notifications",
"validate": "lms.lms.utils.validate_discussion_reply",
},
"Notification Log": {"on_change": "lms.lms.utils.publish_notifications"},
"User": {
"validate": "lms.lms.user.validate_username_duplicates",
@@ -112,6 +129,9 @@ doc_events = {
# Scheduled Tasks
# ---------------
scheduler_events = {
"all": [
"lms.sqlite.build_index_in_background",
],
"hourly": [
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals",
"lms.lms.api.update_course_statistics",
@@ -123,6 +143,7 @@ scheduler_events = {
"lms.lms.doctype.lms_payment.lms_payment.send_payment_reminder",
"lms.lms.doctype.lms_batch.lms_batch.send_batch_start_reminder",
"lms.lms.doctype.lms_live_class.lms_live_class.send_live_class_reminder",
"lms.lms.doctype.lms_course.lms_course.send_notification_for_published_courses",
],
}
@@ -153,7 +174,8 @@ override_whitelisted_methods = {
# Add all simple route rules here
website_route_rules = [
{"from_route": "/lms/<path:app_path>", "to_route": "lms"},
{"from_route": f"/{get_lms_path()}/<path:app_path>", "to_route": "_lms"},
{"from_route": f"/{get_lms_path()}", "to_route": "_lms"},
{
"from_route": "/courses/<course_name>/<certificate_id>",
"to_route": "certificate",
@@ -162,24 +184,25 @@ website_route_rules = [
website_redirects = [
{"source": "/update-profile", "target": "/edit-profile"},
{"source": "/courses", "target": "/lms/courses"},
{"source": "/courses", "target": f"/{get_lms_path()}/courses"},
{
"source": r"^/courses/.*$",
"target": "/lms/courses",
"target": f"/{get_lms_path()}/courses",
},
{"source": "/batches", "target": "/lms/batches"},
{"source": "/batches", "target": f"/{get_lms_path()}/batches"},
{
"source": r"/batches/(.*)",
"target": "/lms/batches",
"target": f"/{get_lms_path()}/batches",
"match_with_query_string": True,
},
{"source": "/job-openings", "target": "/lms/job-openings"},
{"source": "/job-openings", "target": f"/{get_lms_path()}/job-openings"},
{
"source": r"/job-openings/(.*)",
"target": "/lms/job-openings",
"target": f"/{get_lms_path()}/job-openings",
"match_with_query_string": True,
},
{"source": "/statistics", "target": "/lms/statistics"},
{"source": "/statistics", "target": f"/{get_lms_path()}/statistics"},
{"source": "_lms", "target": f"/{get_lms_path()}"},
]
update_website_context = [
@@ -188,17 +211,20 @@ update_website_context = [
jinja = {
"methods": [
"lms.lms.utils.get_signup_optin_checks",
"lms.lms.utils.get_tags",
"lms.lms.utils.get_lesson_count",
"lms.lms.utils.get_instructors",
"lms.lms.utils.get_lesson_index",
"lms.lms.utils.get_lesson_url",
"lms.lms.utils.get_lms_route",
"lms.lms.utils.is_instructor",
"lms.lms.utils.get_palette",
],
"filters": [],
}
extend_bootinfo = [
"lms.lms.utils.extend_bootinfo",
]
## Specify the additional tabs to be included in the user profile page.
## Each entry must be a subclass of lms.lms.plugins.ProfileTab
# profile_tabs = []
@@ -248,7 +274,11 @@ add_to_apps_screen = [
"name": "lms",
"logo": "/assets/lms/frontend/learning.svg",
"title": "Learning",
"route": "/lms",
"route": f"/{get_lms_path()}",
"has_permission": "lms.lms.api.check_app_permission",
}
]
sqlite_search = ["lms.sqlite.LearningSearch"]
auth_hooks = ["lms.auth.authenticate"]
require_type_annotated_api_methods = True

View File

@@ -7,6 +7,7 @@ from lms.lms.api import give_discussions_permission
def after_install():
create_batch_source()
give_discussions_permission()
give_user_list_permission()
def after_sync():
@@ -27,13 +28,6 @@ def create_lms_roles():
create_lms_student_role()
def delete_lms_roles():
roles = ["Course Creator", "Moderator"]
for role in roles:
if frappe.db.exists("Role", role):
frappe.db.delete("Role", role)
def create_course_creator_role():
if frappe.db.exists("Role", "Course Creator"):
frappe.db.set_value("Role", "Course Creator", "desk_access", 0)
@@ -136,7 +130,7 @@ def delete_custom_fields():
"medium",
"linkedin",
"profession",
"looking_for_job",
"open_to",
"cover_image" "work_environment",
"dream_companies",
"career_preference_column",
@@ -185,3 +179,36 @@ def give_lms_roles_to_admin():
doc.parentfield = "roles"
doc.role = role
doc.save()
def give_user_list_permission():
doctype = "User"
roles = ["Course Creator", "Moderator", "Batch Evaluator"]
for role in roles:
permlevel = 0
create_role(doctype, role, permlevel)
create_role(doctype, "System Manager", 1)
def create_role(doctype, role, permlevel):
if not frappe.db.exists("Custom DocPerm", {"parent": doctype, "role": role, "permlevel": permlevel}):
doc = frappe.new_doc("Custom DocPerm")
doc.update(
{
"doctype": "Custom DocPerm",
"parent": doctype,
"role": role,
"read": 1,
"write": 1 if role in ["Moderator", "System Manager"] else 0,
"create": 1 if role == "Moderator" else 0,
"permlevel": permlevel,
}
)
doc.save()
def delete_lms_roles():
roles = ["Course Creator", "Moderator", "Batch Evaluator", "LMS Student"]
for role in roles:
if frappe.db.exists("Role", role):
frappe.db.delete("Role", role)

View File

@@ -14,7 +14,6 @@
"type",
"work_mode",
"status",
"disabled",
"section_break_6",
"company_name",
"company_website",
@@ -97,12 +96,6 @@
"label": "Company Logo",
"reqd": 1
},
{
"default": "0",
"fieldname": "disabled",
"fieldtype": "Check",
"label": "Disabled"
},
{
"fieldname": "company_email_address",
"fieldtype": "Data",
@@ -137,8 +130,8 @@
}
],
"make_attachments_public": 1,
"modified": "2025-09-24 15:32:49.030004",
"modified_by": "Administrator",
"modified": "2026-02-19 14:26:14.027340",
"modified_by": "sayali@frappe.io",
"module": "Job",
"name": "Job Opportunity",
"owner": "Administrator",
@@ -156,24 +149,16 @@
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"select": 1,
"share": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"select": 1,
"share": 1,
"write": 1
}

View File

@@ -16,7 +16,7 @@ class JobOpportunity(Document):
self.company_logo = validate_image(self.company_logo)
def validate_urls(self):
validate_url(self.company_website, True)
validate_url(self.company_website, True, ["http", "https"])
def autoname(self):
if not self.name:
@@ -35,7 +35,7 @@ def update_job_openings():
@frappe.whitelist()
def report(job, reason):
def report(job: str, reason: str):
system_managers = get_system_managers(only_name=True)
user = frappe.db.get_value("User", frappe.session.user, "full_name")
subject = _("User {0} has reported the job post {1}").format(user, job)

View File

@@ -1,7 +0,0 @@
// Copyright (c) 2022, Frappe and contributors
// For license information, please see license.txt
frappe.ui.form.on("Job Settings", {
// refresh: function(frm) {
// }
});

View File

@@ -1,54 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2022-02-07 12:01:41.422955",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"allow_posting",
"title",
"subtitle"
],
"fields": [
{
"default": "0",
"fieldname": "allow_posting",
"fieldtype": "Check",
"label": "Allow Job Posting From Website"
},
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Job Board Title"
},
{
"fieldname": "subtitle",
"fieldtype": "Data",
"label": "Job Board Subtitle"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2022-02-11 15:56:38.958317",
"modified_by": "Administrator",
"module": "Job",
"name": "Job Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@@ -1,9 +0,0 @@
# Copyright (c) 2022, Frappe and Contributors
# See license.txt
# import frappe
import unittest
class TestJobSettings(unittest.TestCase):
pass

View File

@@ -60,7 +60,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-02-20 20:10:46.943871",
"modified": "2025-11-01 14:03:02.903943",
"modified_by": "Administrator",
"module": "Job",
"name": "LMS Job Application",
@@ -82,6 +82,7 @@
"create": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -90,8 +91,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "user"
}
}

View File

@@ -11,6 +11,10 @@ class LMSJobApplication(Document):
self.validate_duplicate()
def after_insert(self):
job_owner = frappe.get_value("Job Opportunity", self.job, "owner")
if job_owner:
frappe.share.add_docshare("LMS Job Application", self.name, job_owner, read=1)
outgoing_email_account = frappe.get_cached_value(
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
)
@@ -33,7 +37,7 @@ class LMSJobApplication(Document):
resume = frappe.get_doc(
"File",
{
"file_name": self.resume,
"file_url": self.resume,
},
)
frappe.sendmail(

File diff suppressed because it is too large Load Diff

View File

@@ -7,12 +7,12 @@
"doctype": "Dashboard Chart",
"document_type": "LMS Certificate",
"dynamic_filters_json": "[]",
"filters_json": "[[\"LMS Certificate\",\"published\",\"=\",1,false]]",
"filters_json": "[[\"LMS Certificate\",\"published\",\"=\",1]]",
"group_by_type": "Count",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"modified": "2025-04-28 17:47:28.517149",
"modified": "2025-12-07 17:47:28.517150",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "Certification",

View File

@@ -9,19 +9,20 @@
"doctype": "Dashboard Chart",
"document_type": "User",
"dynamic_filters_json": "[]",
"filters_json": "[[\"User\",\"enabled\",\"=\",1,false]]",
"filters_json": "[[\"User\",\"enabled\",\"=\",1]]",
"group_by_type": "Count",
"idx": 5,
"is_public": 1,
"is_standard": 1,
"last_synced_on": "2025-04-28 15:09:52.161688",
"modified": "2025-04-28 17:47:58.168293",
"last_synced_on": "2025-12-08 13:05:16.186243",
"modified": "2025-12-09 13:08:50.049053",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "New Signups",
"number_of_groups": 0,
"owner": "basawaraj@erpnext.com",
"roles": [],
"show_values_over_chart": 0,
"source": "",
"time_interval": "Daily",
"timeseries": 1,

View File

@@ -1,7 +0,0 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on("Cohort", {
// refresh: function(frm) {
// }
});

View File

@@ -1,133 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "format:{course}/{slug}",
"creation": "2021-11-19 11:45:31.016097",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"course",
"title",
"slug",
"section_break_2",
"instructor",
"status",
"column_break_4",
"begin_date",
"end_date",
"duration",
"section_break_8",
"description",
"pages"
],
"fields": [
{
"fieldname": "description",
"fieldtype": "Markdown Editor",
"label": "Description"
},
{
"fieldname": "instructor",
"fieldtype": "Link",
"label": "Instructor",
"options": "User",
"reqd": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"label": "Status",
"options": "Upcoming\nLive\nCompleted\nCancelled",
"reqd": 1
},
{
"fieldname": "begin_date",
"fieldtype": "Date",
"label": "Begin Date"
},
{
"fieldname": "end_date",
"fieldtype": "Date",
"label": "End Date"
},
{
"fieldname": "duration",
"fieldtype": "Data",
"label": "Duration"
},
{
"fieldname": "slug",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Slug",
"reqd": 1,
"unique": 1
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_8",
"fieldtype": "Section Break"
},
{
"fieldname": "section_break_2",
"fieldtype": "Section Break"
},
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title",
"reqd": 1
},
{
"fieldname": "course",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Course",
"options": "LMS Course",
"reqd": 1
},
{
"fieldname": "pages",
"fieldtype": "Table",
"label": "Pages",
"options": "Cohort Web Page"
}
],
"index_web_pages_for_search": 1,
"links": [
{
"group": "Links",
"link_doctype": "Cohort Subgroup",
"link_fieldname": "cohort"
}
],
"modified": "2022-10-13 15:46:32.322926",
"modified_by": "Administrator",
"module": "LMS",
"name": "Cohort",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -1,79 +0,0 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
class Cohort(Document):
def get_url(self):
return f"{frappe.utils.get_url()}/lms/courses/{self.course}/cohorts/{self.slug}"
def get_subgroups(self, include_counts=False, sort_by=None):
names = frappe.get_all("Cohort Subgroup", filters={"cohort": self.name}, pluck="name")
subgroups = [frappe.get_cached_doc("Cohort Subgroup", name) for name in names]
subgroups = sorted(subgroups, key=lambda sg: sg.title)
if include_counts:
mentors = self._get_subgroup_counts("Cohort Mentor")
students = self._get_subgroup_counts("LMS Enrollment")
join_requests = self._get_subgroup_counts("Cohort Join Request", status="Pending")
for s in subgroups:
s.num_mentors = mentors.get(s.name, 0)
s.num_students = students.get(s.name, 0)
s.num_join_requests = join_requests.get(s.name, 0)
if sort_by:
subgroups.sort(key=lambda sg: getattr(sg, sort_by), reverse=True)
return subgroups
def _get_subgroup_counts(self, doctype, **kw):
rows = frappe.get_all(
doctype,
filters={"cohort": self.name, **kw},
fields=["subgroup", "count(*) as count"],
group_by="subgroup",
)
return {row["subgroup"]: row["count"] for row in rows}
def _get_count(self, doctype, **kw):
filters = {"cohort": self.name, **kw}
return frappe.db.count(doctype, filters=filters)
def get_page_template(self, slug, scope=None):
p = self.get_page(slug, scope=scope)
return p and p.get_template_html()
def get_page(self, slug, scope=None):
for p in self.pages:
if p.slug == slug and scope in [p.scope, None]:
return p
def get_pages(self, scope=None):
return [p for p in self.pages if scope in [p.scope, None]]
def get_stats(self):
return {
"subgroups": self._get_count("Cohort Subgroup"),
"mentors": self._get_count("Cohort Mentor"),
"students": self._get_count("LMS Enrollment"),
"join_requests": self._get_count("Cohort Join Request", status="Pending"),
}
def get_subgroup(self, slug):
q = dict(cohort=self.name, slug=slug)
name = frappe.db.get_value("Cohort Subgroup", q, "name")
return name and frappe.get_doc("Cohort Subgroup", name)
def get_mentor(self, email):
q = dict(cohort=self.name, email=email)
name = frappe.db.get_value("Cohort Mentor", q, "name")
return name and frappe.get_doc("Cohort Mentor", name)
def is_mentor(self, email):
q = {"doctype": "Cohort Mentor", "cohort": self.name, "email": email}
return frappe.db.exists(q)
def is_admin(self, email):
q = {"doctype": "Cohort Staff", "cohort": self.name, "email": email, "role": "Admin"}
return frappe.db.exists(q)

View File

@@ -1,9 +0,0 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
class TestCohort(unittest.TestCase):
pass

View File

@@ -1,7 +0,0 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on("Cohort Join Request", {
// refresh: function(frm) {
// }
});

View File

@@ -1,88 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2021-11-19 16:27:41.716509",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"cohort",
"email",
"column_break_3",
"subgroup",
"status"
],
"fields": [
{
"fieldname": "cohort",
"fieldtype": "Link",
"label": "Cohort",
"options": "Cohort",
"reqd": 1
},
{
"fieldname": "subgroup",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Subgroup",
"options": "Cohort Subgroup",
"reqd": 1
},
{
"fieldname": "email",
"fieldtype": "Link",
"in_list_view": 1,
"label": "E-Mail",
"options": "User",
"reqd": 1
},
{
"default": "Pending",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "Pending\nAccepted\nRejected"
},
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-09-29 17:08:18.950560",
"modified_by": "Administrator",
"module": "LMS",
"name": "Cohort Join Request",
"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,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -1,52 +0,0 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
class CohortJoinRequest(Document):
def on_update(self):
if self.status == "Accepted":
self.ensure_student()
def ensure_student(self):
# case 1 - user is already a member
q = {
"doctype": "LMS Enrollment",
"cohort": self.cohort,
"subgroup": self.subgroup,
"member": self.email,
"member_type": "Student",
}
if frappe.db.exists(q):
return
# case 2 - user has signed up for this course, possibly not this cohort
cohort = frappe.get_doc("Cohort", self.cohort)
q = {
"doctype": "LMS Enrollment",
"course": cohort.course,
"member": self.email,
"member_type": "Student",
}
name = frappe.db.exists(q)
if name:
doc = frappe.get_doc("LMS Enrollment", name)
doc.cohort = self.cohort
doc.subgroup = self.subgroup
doc.save(ignore_permissions=True)
else:
# case 3 - user has not signed up for this course yet
data = {
"doctype": "LMS Enrollment",
"course": cohort.course,
"cohort": self.cohort,
"subgroup": self.subgroup,
"member": self.email,
"member_type": "Student",
"role": "Member",
}
doc = frappe.get_doc(data)
doc.insert(ignore_permissions=True)

View File

@@ -1,9 +0,0 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
class TestCohortJoinRequest(unittest.TestCase):
pass

View File

@@ -1,7 +0,0 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on("Cohort Mentor", {
// refresh: function(frm) {
// }
});

View File

@@ -1,73 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2021-11-19 15:31:47.129156",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"cohort",
"email",
"subgroup",
"course"
],
"fields": [
{
"fieldname": "cohort",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Cohort",
"options": "Cohort",
"reqd": 1
},
{
"fieldname": "email",
"fieldtype": "Link",
"in_list_view": 1,
"label": "E-mail",
"options": "User",
"reqd": 1
},
{
"fieldname": "subgroup",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Primary Subgroup",
"options": "Cohort Subgroup",
"reqd": 1
},
{
"fetch_from": "cohort.course",
"fieldname": "course",
"fieldtype": "Link",
"label": "Course",
"options": "LMS Course",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-11-29 16:32:33.235281",
"modified_by": "Administrator",
"module": "LMS",
"name": "Cohort Mentor",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -1,13 +0,0 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
class CohortMentor(Document):
def get_subgroup(self):
return frappe.get_doc("Cohort Subgroup", self.subgroup)
def get_user(self):
return frappe.get_doc("User", self.email)

View File

@@ -1,9 +0,0 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
class TestCohortMentor(unittest.TestCase):
pass

View File

@@ -1,7 +0,0 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on("Cohort Staff", {
// refresh: function(frm) {
// }
});

View File

@@ -1,77 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2021-11-19 15:35:00.551949",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"cohort",
"course",
"column_break_3",
"email",
"role"
],
"fields": [
{
"fieldname": "cohort",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Cohort",
"options": "Cohort",
"reqd": 1
},
{
"fieldname": "email",
"fieldtype": "Link",
"in_list_view": 1,
"label": "User",
"options": "User",
"reqd": 1
},
{
"fieldname": "role",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Role",
"options": "Admin\nManager\nStaff",
"reqd": 1
},
{
"fetch_from": "cohort.course",
"fieldname": "course",
"fieldtype": "Link",
"label": "Course",
"options": "LMS Course",
"read_only": 1
},
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-12-16 15:16:04.042372",
"modified_by": "Administrator",
"module": "LMS",
"name": "Cohort Staff",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

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

View File

@@ -1,9 +0,0 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
class TestCohortStaff(unittest.TestCase):
pass

View File

@@ -1,7 +0,0 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on("Cohort Subgroup", {
// refresh: function(frm) {
// }
});

View File

@@ -1,104 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "format:{title} ({cohort})",
"creation": "2021-11-19 11:50:27.312434",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"cohort",
"slug",
"title",
"column_break_4",
"invite_code",
"course",
"section_break_7",
"description"
],
"fields": [
{
"fieldname": "cohort",
"fieldtype": "Link",
"in_list_view": 1,
"in_preview": 1,
"in_standard_filter": 1,
"label": "Cohort",
"options": "Cohort",
"reqd": 1
},
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"in_preview": 1,
"in_standard_filter": 1,
"label": "Title",
"reqd": 1
},
{
"fieldname": "description",
"fieldtype": "Markdown Editor",
"label": "Description"
},
{
"fieldname": "invite_code",
"fieldtype": "Data",
"label": "Invite Code",
"read_only": 1
},
{
"fieldname": "slug",
"fieldtype": "Data",
"label": "Slug",
"reqd": 1
},
{
"fetch_from": "cohort.course",
"fieldname": "course",
"fieldtype": "Link",
"label": "Course",
"options": "LMS Course",
"read_only": 1
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_7",
"fieldtype": "Section Break"
}
],
"index_web_pages_for_search": 1,
"links": [
{
"group": "Links",
"link_doctype": "Cohort Join Request",
"link_fieldname": "subgroup"
}
],
"modified": "2021-12-16 15:12:42.504883",
"modified_by": "Administrator",
"module": "LMS",
"name": "Cohort Subgroup",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -1,84 +0,0 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
from frappe.utils import random_string
class CohortSubgroup(Document):
def before_save(self):
if not self.invite_code:
self.invite_code = random_string(8)
def get_url(self):
cohort = frappe.get_doc("Cohort", self.cohort)
return f"{frappe.utils.get_url()}/lms/courses/{self.course}/subgroups/{cohort.slug}/{self.slug}"
def get_invite_link(self):
cohort = frappe.get_doc("Cohort", self.cohort)
return f"{frappe.utils.get_url()}/lms/courses/{self.course}/join/{cohort.slug}/{self.slug}/{self.invite_code}"
def has_student(self, email):
"""Check if given user is a student of this subgroup."""
q = {"doctype": "LMS Enrollment", "subgroup": self.name, "member": email}
return frappe.db.exists(q)
def has_join_request(self, email):
"""Check if given user is a student of this subgroup."""
q = {"doctype": "Cohort Join Request", "subgroup": self.name, "email": email}
return frappe.db.exists(q)
def get_join_requests(self, status="Pending"):
q = {"subgroup": self.name, "status": status}
return frappe.get_all("Cohort Join Request", filters=q, fields=["*"], order_by="creation desc")
def get_mentors(self):
emails = frappe.get_all(
"Cohort Mentor", filters={"subgroup": self.name}, fields=["email"], pluck="email"
)
return self._get_users(emails)
def get_students(self):
emails = frappe.get_all(
"LMS Enrollment",
filters={"subgroup": self.name},
fields=["member"],
pluck="member",
page_length=1000,
)
return self._get_users(emails)
def _get_users(self, emails):
users = [frappe.get_cached_doc("User", email) for email in emails]
return sorted(users, key=lambda user: user.full_name)
def is_mentor(self, email):
q = {"doctype": "Cohort Mentor", "subgroup": self.name, "email": email}
return frappe.db.exists(q)
def is_manager(self, email):
"""Returns True if the given user is a manager of this subgroup.
Mentors of the subgroup, admins of the Cohort are considered as managers.
"""
return self.is_mentor(email) or self.get_cohort().is_admin(email)
def get_cohort(self):
return frappe.get_doc("Cohort", self.cohort)
def add_mentor(self, email):
d = {
"doctype": "Cohort Mentor",
"subgroup": self.name,
"cohort": self.cohort,
"email": email,
}
if frappe.db.exists(d):
return
doc = frappe.get_doc(d)
doc.insert(ignore_permissions=True)
# def after_doctype_insert():
# frappe.db.add_unique("Cohort Subgroup", ("cohort", "slug"))

View File

@@ -1,9 +0,0 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
class TestCohortSubgroup(unittest.TestCase):
pass

View File

@@ -1,64 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2021-12-04 23:28:40.429867",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"slug",
"title",
"template",
"scope",
"required_role"
],
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title",
"reqd": 1
},
{
"fieldname": "template",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Template",
"options": "Web Template",
"reqd": 1
},
{
"default": "Cohort",
"fieldname": "scope",
"fieldtype": "Select",
"label": "Scope",
"options": "Cohort\nSubgroup"
},
{
"default": "Public",
"fieldname": "required_role",
"fieldtype": "Select",
"label": "Required Role",
"options": "Public\nStudent\nMentor\nAdmin"
},
{
"fieldname": "slug",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Slug",
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-12-04 23:33:03.954128",
"modified_by": "Administrator",
"module": "LMS",
"name": "Cohort Web Page",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC"
}

View File

@@ -1,10 +0,0 @@
# Copyright (c) 2021, Frappe and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
class CohortWebPage(Document):
def get_template_html(self):
return frappe.get_doc("Web Template", self.template).template

View File

@@ -4,25 +4,12 @@
import frappe
from frappe.model.document import Document
from lms.lms.utils import get_course_progress, get_lesson_count
from lms.lms.utils import get_lesson_count
class CourseChapter(Document):
def on_update(self):
self.recalculate_course_progress()
self.update_lesson_count()
frappe.enqueue(method=self.recalculate_course_progress, queue="short", timeout=300, is_async=True)
def recalculate_course_progress(self):
"""Recalculate course progress if a new lesson is added or removed"""
previous_lessons = self.get_doc_before_save() and self.get_doc_before_save().as_dict().lessons
current_lessons = self.lessons
if previous_lessons and previous_lessons != current_lessons:
enrolled_members = frappe.get_all("LMS Enrollment", {"course": self.course}, ["member", "name"])
for enrollment in enrolled_members:
new_progress = get_course_progress(self.course, enrollment.member)
frappe.db.set_value("LMS Enrollment", enrollment.name, "progress", new_progress)
def update_lesson_count(self):
"""Update lesson count in the course"""

View File

@@ -1,9 +1,39 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
import frappe
from lms.lms.api import delete_chapter
from lms.lms.test_helpers import BaseTestUtils
class TestCourseChapter(unittest.TestCase):
pass
class TestCourseChapter(BaseTestUtils):
def setUp(self):
super().setUp()
self.instructor = self._create_user(
"frappe@example.com", "Frappe", "Admin", ["Moderator", "Course Creator"]
)
def tearDown(self):
return super().tearDown()
def test_chapter_deletion_and_renumbering(self):
course = self._create_course(f"Test Renumbering Course {frappe.generate_hash()[:8]}")
chapters = []
for i in range(1, 4):
chapter = self._create_chapter(f"Chapter {i}", course.name)
chapters.append(chapter)
self._create_chapter_reference(course.name, chapter.name, i)
self.assertEqual(self._get_chapter_index(course.name, chapter.name), i)
delete_chapter(chapters[1].name)
idx_ch1 = self._get_chapter_index(course.name, chapters[0].name)
idx_ch3 = self._get_chapter_index(course.name, chapters[2].name)
self.assertEqual(idx_ch1, 1, "Chapter 1 index should remain 1")
self.assertEqual(idx_ch3, 2, "Chapter 3 index should be renumbered to 2 after deleting Chapter 2")
def _get_chapter_index(self, course, chapter):
return frappe.db.get_value("Chapter Reference", {"parent": course, "chapter": chapter}, "idx")

View File

@@ -83,8 +83,8 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-07-04 12:04:11.007945",
"modified_by": "Administrator",
"modified": "2026-02-23 14:50:11.733278",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "Course Evaluator",
"naming_rule": "By fieldname",
@@ -125,11 +125,24 @@
"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
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "full_name"
"title_field": "full_name",
"track_changes": 1
}

View File

@@ -6,7 +6,7 @@ from datetime import datetime
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import get_time, getdate
from frappe.utils import add_days, get_time, getdate, nowdate
from lms.lms.utils import get_evaluator
@@ -58,29 +58,125 @@ class CourseEvaluator(Document):
@frappe.whitelist()
def get_schedule(course, date, batch=None):
def get_schedule(course: str, batch: str = None):
evaluator = get_evaluator(course, batch)
day = datetime.strptime(date, "%Y-%m-%d").strftime("%A")
start_date = nowdate()
end_date = get_schedule_range_end_date(start_date, batch)
all_slots = get_all_slots(evaluator, start_date, end_date)
booked_slots = get_booked_slots(evaluator, start_date, end_date)
all_slots = remove_booked_slots(all_slots, booked_slots)
return all_slots
all_slots = frappe.get_all(
def get_all_slots(evaluator, start_date, end_date):
schedule = get_evaluator_schedule(evaluator)
unavailable_dates = get_unavailable_dates(evaluator)
all_slots = []
current_date = getdate(start_date)
end_date = getdate(end_date)
while current_date <= end_date:
if current_date in unavailable_dates:
current_date = add_days(current_date, 1)
continue
day_of_week = current_date.strftime("%A")
slots_for_day = [x for x in schedule if x.day == day_of_week]
for slot in slots_for_day:
all_slots.append(
frappe._dict(
{
"day": day_of_week,
"date": current_date,
"start_time": slot.start_time,
"end_time": slot.end_time,
}
)
)
current_date = add_days(current_date, 1)
return all_slots
def get_evaluator_schedule(evaluator):
return frappe.get_all(
"Evaluator Schedule",
filters={
"parent": evaluator,
"day": day,
},
fields=["day", "start_time", "end_time"],
order_by="start_time",
)
booked_slots = frappe.get_all(
def get_booked_slots(evaluator, start_date, end_date):
date = ["between", [start_date, end_date]]
return frappe.get_all(
"LMS Certificate Request",
filters={"evaluator": evaluator, "date": date},
fields=["start_time", "day"],
filters={
"evaluator": evaluator,
"date": date,
"status": ["!=", "Cancelled"],
},
fields=["start_time", "day", "date"],
)
for slot in booked_slots:
same_slot = [x for x in all_slots if x.start_time == slot.start_time and x.day == slot.day]
if len(same_slot):
all_slots.remove(same_slot[0])
return all_slots
def remove_booked_slots(all_slots, booked_slots):
slots_to_remove = []
for slot in all_slots:
for booked in booked_slots:
if slot.date == booked.date and slot.start_time == booked.start_time:
slots_to_remove.append(slot)
for slot in slots_to_remove:
all_slots.remove(slot)
return group_slots_by_date(all_slots)
def group_slots_by_date(all_slots):
slots_by_date = []
dates_included = set()
for slot in all_slots:
date_str = slot.get("date").strftime("%Y-%m-%d")
if date_str not in dates_included:
slots_by_date.append({"date": date_str, "day": slot.day, "slots": []})
dates_included.add(date_str)
for date_slot in slots_by_date:
if date_slot.get("date") == date_str:
date_slot.get("slots").append(
{
"start_time": slot.get("start_time"),
"end_time": slot.get("end_time"),
}
)
return slots_by_date
def get_evaluator_availability(evaluator):
return frappe.db.get_value(
"Course Evaluator", evaluator, ["unavailable_from", "unavailable_to"], as_dict=1
)
def get_unavailable_dates(evaluator):
availability = get_evaluator_availability(evaluator)
unavailable_dates = []
if availability.unavailable_from and availability.unavailable_to:
current_date = getdate(availability.unavailable_from)
end_date = getdate(availability.unavailable_to)
while current_date <= end_date:
unavailable_dates.append(current_date)
current_date = add_days(current_date, 1)
return unavailable_dates
def get_schedule_range_end_date(start_date, batch=None):
end_date = add_days(start_date, 60)
if batch:
batch_end_date = frappe.db.get_value("LMS Batch", batch, "evaluation_end_date")
if batch_end_date and batch_end_date < getdate(end_date):
end_date = getdate(batch_end_date)
return end_date

View File

@@ -1,9 +1,65 @@
# Copyright (c) 2022, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import UnitTestCase
from frappe.utils import add_days, format_time, getdate
from lms.lms.doctype.course_evaluator.course_evaluator import get_schedule, get_schedule_range_end_date
from lms.lms.test_helpers import BaseTestUtils
class TestCourseEvaluator(UnitTestCase):
pass
class TestCourseEvaluator(BaseTestUtils):
def setUp(self):
super().setUp()
self.admin = self._create_user(
"frappe@example.com", "Frappe", "Admin", ["Moderator", "Course Creator", "Batch Evaluator"]
)
self.course = self._create_course()
self.evaluator = self._create_evaluator()
self.batch = self._create_batch(self.course.name)
def test_schedule_day_and_time(self):
schedule = get_schedule(self.batch.courses[0].course, self.batch.name)
days = ["Monday", "Wednesday"]
self.assertGreaterEqual(len(schedule), 14)
for row in schedule:
self.assertIn(row.get("day"), days)
if row.get("day") == "Monday":
for slot in row.get("slots"):
self.assertEqual(format_time(slot.get("start_time"), "HH:mm:ss"), "10:00:00")
self.assertEqual(format_time(slot.get("end_time"), "HH:mm:ss"), "12:00:00")
if row.get("day") == "Wednesday":
for slot in row.get("slots"):
self.assertEqual(format_time(slot.get("start_time"), "HH:mm:ss"), "14:00:00")
self.assertEqual(format_time(slot.get("end_time"), "HH:mm:ss"), "16:00:00")
def test_schedule_dates(self):
schedule = get_schedule(self.batch.courses[0].course, self.batch.name)
first_date = self.calculated_first_date_of_schedule()
last_date = self.calculated_last_date_of_schedule()
self.assertEqual(getdate(schedule[0].get("date")), first_date)
self.assertEqual(getdate(schedule[-1].get("date")), last_date)
def calculated_first_date_of_schedule(self):
today = getdate()
offset_monday = (0 - today.weekday() + 7) % 7 # 0 for Monday
offset_wednesday = (2 - today.weekday() + 7) % 7 # 2 for Wednesday
if offset_monday < offset_wednesday:
first_date = add_days(today, offset_monday)
else:
first_date = add_days(today, offset_wednesday)
return first_date
def calculated_last_date_of_schedule(self):
last_day = getdate(get_schedule_range_end_date(getdate(), self.batch.name))
while last_day.weekday() not in (0, 2):
last_day = add_days(last_day, -1)
return last_day
def test_unavailability_dates(self):
unavailable_from = getdate(self.evaluator.unavailable_from)
unavailable_to = getdate(self.evaluator.unavailable_to)
schedule = get_schedule(self.batch.courses[0].course, self.batch.name)
for row in schedule:
schedule_date = getdate(row.get("date"))
self.assertFalse(unavailable_from < schedule_date < unavailable_to)

View File

@@ -68,7 +68,6 @@
{
"fieldname": "body",
"fieldtype": "Markdown Editor",
"ignore_xss_filter": 1,
"label": "Body"
},
{
@@ -168,7 +167,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-04-10 15:19:22.400932",
"modified": "2026-02-20 13:49:25.599827",
"modified_by": "Administrator",
"module": "LMS",
"name": "Course Lesson",

View File

@@ -9,15 +9,39 @@ from frappe.model.document import Document
from frappe.realtime import get_website_room
from frappe.utils.telemetry import capture
from lms.lms.utils import get_course_progress
from lms.lms.utils import get_course_progress, recalculate_course_progress
from ...md import find_macros
class CourseLesson(Document):
def after_insert(self):
self.validate_progress_recalculation()
def after_delete(self):
self.validate_progress_recalculation()
def on_update(self):
self.validate_quiz_id()
def validate_progress_recalculation(self):
if not self.course or not self.chapter:
return
enrollments = frappe.db.get_all(
"LMS Enrollment",
filters={"course": self.course},
fields=["name", "member"],
)
if not len(enrollments):
return
frappe.enqueue(method=self.recalculate_progress, queue="long", is_async=True, enrollments=enrollments)
def recalculate_progress(self, enrollments):
for enrollment in enrollments:
recalculate_course_progress(self.course, enrollment.member)
def validate_quiz_id(self):
if self.quiz_id and not frappe.db.exists("LMS Quiz", self.quiz_id):
frappe.throw(_("Invalid Quiz ID"))
@@ -46,7 +70,7 @@ class CourseLesson(Document):
@frappe.whitelist()
def save_progress(lesson, course, scorm_details=None):
def save_progress(lesson: str, course: str, scorm_details: dict = None):
"""
Note: Pass the argument scorm_details as a dict if it is SCORM related save_progress
"""
@@ -103,7 +127,7 @@ def save_progress(lesson, course, scorm_details=None):
)
progress = get_course_progress(course)
capture_progress_for_analytics(progress, course)
capture_progress_for_analytics()
# Had to get doc, as on_change doesn't trigger when you use set_value. The trigger is necessary for badge to get assigned.
enrollment = frappe.get_doc("LMS Enrollment", membership)
@@ -121,9 +145,8 @@ def save_progress(lesson, course, scorm_details=None):
return progress
def capture_progress_for_analytics(progress, course):
if progress in [25, 50, 75, 100]:
capture("course_progress", "lms", properties={"course": course, "progress": progress})
def capture_progress_for_analytics():
capture("course_progress", "lms")
def get_quiz_progress(lesson):
@@ -182,8 +205,3 @@ def get_assignment_progress(lesson):
):
return False
return True
@frappe.whitelist()
def get_lesson_info(chapter):
return frappe.db.get_value("Course Chapter", chapter, "course")

View File

@@ -1,7 +0,0 @@
// Copyright (c) 2021, Frappe and contributors
// For license information, please see license.txt
frappe.ui.form.on("Exercise Latest Submission", {
// refresh: function(frm) {
// }
});

View File

@@ -1,166 +0,0 @@
{
"actions": [],
"creation": "2021-12-08 17:56:26.049675",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"exercise",
"status",
"batch_old",
"column_break_4",
"exercise_title",
"course",
"lesson",
"section_break_8",
"solution",
"image",
"test_results",
"comments",
"latest_submission",
"member",
"member_email",
"member_cohort",
"member_subgroup"
],
"fields": [
{
"fieldname": "exercise",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Exercise",
"options": "LMS Exercise",
"search_index": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"label": "Status",
"options": "Correct\nIncorrect"
},
{
"fieldname": "batch_old",
"fieldtype": "Link",
"label": "Batch Old",
"options": "LMS Batch Old"
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fetch_from": "exercise.title",
"fieldname": "exercise_title",
"fieldtype": "Data",
"label": "Exercise Title",
"read_only": 1
},
{
"fetch_from": "exercise.course",
"fieldname": "course",
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Course",
"options": "LMS Course",
"read_only": 1
},
{
"fetch_from": "exercise.lesson",
"fieldname": "lesson",
"fieldtype": "Link",
"label": "Lesson",
"options": "Course Lesson"
},
{
"fieldname": "section_break_8",
"fieldtype": "Section Break"
},
{
"fetch_from": "latest_submission.solution",
"fieldname": "solution",
"fieldtype": "Code",
"label": "Solution"
},
{
"fetch_from": "latest_submission.image",
"fieldname": "image",
"fieldtype": "Code",
"label": "Image",
"read_only": 1
},
{
"fetch_from": "latest_submission.test_results",
"fieldname": "test_results",
"fieldtype": "Small Text",
"label": "Test Results"
},
{
"fieldname": "comments",
"fieldtype": "Small Text",
"label": "Comments"
},
{
"fieldname": "latest_submission",
"fieldtype": "Link",
"label": "Latest Submission",
"options": "Exercise Submission"
},
{
"fieldname": "member",
"fieldtype": "Link",
"label": "Member",
"options": "LMS Enrollment"
},
{
"fetch_from": "member.member",
"fieldname": "member_email",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Member Email",
"options": "User",
"search_index": 1
},
{
"fetch_from": "member.cohort",
"fieldname": "member_cohort",
"fieldtype": "Link",
"label": "Member Cohort",
"options": "Cohort",
"search_index": 1
},
{
"fetch_from": "member.subgroup",
"fieldname": "member_subgroup",
"fieldtype": "Link",
"label": "Member Subgroup",
"options": "Cohort Subgroup",
"search_index": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-12-08 22:58:46.312863",
"modified_by": "Administrator",
"module": "LMS",
"name": "Exercise Latest Submission",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

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

View File

@@ -1,9 +0,0 @@
# Copyright (c) 2021, Frappe and Contributors
# See license.txt
# import frappe
import unittest
class TestExerciseLatestSubmission(unittest.TestCase):
pass

View File

@@ -1,7 +0,0 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on("Exercise Submission", {
// refresh: function(frm) {
// }
});

View File

@@ -1,126 +0,0 @@
{
"actions": [],
"creation": "2021-05-19 11:41:18.108316",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"exercise",
"status",
"batch_old",
"column_break_4",
"exercise_title",
"course",
"lesson",
"section_break_8",
"solution",
"image",
"test_results",
"comments",
"member"
],
"fields": [
{
"fieldname": "exercise",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Exercise",
"options": "LMS Exercise"
},
{
"fetch_from": "exercise.title",
"fieldname": "exercise_title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Exercise Title",
"read_only": 1
},
{
"fetch_from": "exercise.course",
"fieldname": "course",
"fieldtype": "Link",
"label": "Course",
"options": "LMS Course",
"read_only": 1
},
{
"fieldname": "batch_old",
"fieldtype": "Link",
"label": "Batch Old",
"options": "LMS Batch Old"
},
{
"fetch_from": "exercise.lesson",
"fieldname": "lesson",
"fieldtype": "Link",
"label": "Lesson",
"options": "Course Lesson"
},
{
"fieldname": "image",
"fieldtype": "Code",
"label": "Image",
"read_only": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"label": "Status",
"options": "Correct\nIncorrect"
},
{
"fieldname": "test_results",
"fieldtype": "Small Text",
"label": "Test Results"
},
{
"fieldname": "comments",
"fieldtype": "Small Text",
"label": "Comments"
},
{
"fieldname": "solution",
"fieldtype": "Code",
"in_list_view": 1,
"label": "Solution"
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_8",
"fieldtype": "Section Break"
},
{
"fieldname": "member",
"fieldtype": "Link",
"label": "Member",
"options": "LMS Enrollment"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-12-08 22:25:05.809377",
"modified_by": "Administrator",
"module": "LMS",
"name": "Exercise Submission",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -1,29 +0,0 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
class ExerciseSubmission(Document):
def on_update(self):
self.update_latest_submission()
def update_latest_submission(self):
names = frappe.get_all(
"Exercise Latest Submission", {"exercise": self.exercise, "member": self.member}
)
if names:
doc = frappe.get_doc("Exercise Latest Submission", names[0])
doc.latest_submission = self.name
doc.save(ignore_permissions=True)
else:
doc = frappe.get_doc(
{
"doctype": "Exercise Latest Submission",
"exercise": self.exercise,
"member": self.member,
"latest_submission": self.name,
}
)
doc.insert(ignore_permissions=True)

View File

@@ -1,9 +0,0 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
class TestExerciseSubmission(unittest.TestCase):
pass

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "format: ASG-{#####}",
"creation": "2023-05-26 19:41:26.025081",
@@ -9,10 +10,11 @@
"engine": "InnoDB",
"field_order": [
"title",
"question",
"column_break_hmwv",
"type",
"grade_assignment",
"course",
"column_break_hmwv",
"question",
"section_break_sjti",
"show_answer",
"answer"
@@ -68,12 +70,24 @@
{
"fieldname": "section_break_sjti",
"fieldtype": "Section Break"
},
{
"fieldname": "course",
"fieldtype": "Link",
"label": "Course",
"options": "LMS Course"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-12-24 09:36:31.464508",
"modified_by": "Administrator",
"links": [
{
"link_doctype": "LMS Assignment Submission",
"link_fieldname": "assignment"
}
],
"modified": "2026-02-05 11:37:36.492016",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Assignment",
"naming_rule": "Expression (old style)",
@@ -96,6 +110,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -111,11 +126,37 @@
"report": 1,
"role": "LMS Student",
"share": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "title"
}
"title_field": "title",
"track_changes": 1
}

View File

@@ -4,23 +4,8 @@
import frappe
from frappe.model.document import Document
from lms.lms.utils import has_course_instructor_role, has_course_moderator_role
from lms.lms.utils import has_course_instructor_role, has_moderator_role
class LMSAssignment(Document):
pass
@frappe.whitelist()
def save_assignment(assignment, title, type, question):
if not has_course_moderator_role() or not has_course_instructor_role():
return
if assignment:
doc = frappe.get_doc("LMS Assignment", assignment)
else:
doc = frappe.get_doc({"doctype": "LMS Assignment"})
doc.update({"title": title, "type": type, "question": question})
doc.save(ignore_permissions=True)
return doc.name

View File

@@ -42,7 +42,8 @@
"fieldname": "assignment",
"fieldtype": "Link",
"label": "Assignment",
"options": "LMS Assignment"
"options": "LMS Assignment",
"reqd": 1
},
{
"fieldname": "member",
@@ -150,7 +151,7 @@
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2025-07-14 10:24:23.526176",
"modified": "2026-02-05 11:38:03.792865",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Assignment Submission",
@@ -195,7 +196,6 @@
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
@@ -207,7 +207,6 @@
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,

View File

@@ -7,6 +7,8 @@ from frappe.desk.doctype.notification_log.notification_log import make_notificat
from frappe.model.document import Document
from frappe.utils import validate_url
from lms.lms.utils import get_lms_route
class LMSAssignmentSubmission(Document):
def validate(self):
@@ -28,7 +30,7 @@ class LMSAssignmentSubmission(Document):
)
def validate_url(self):
if self.type == "URL" and not validate_url(self.answer):
if self.type == "URL" and not validate_url(self.answer, True, ["http", "https"]):
frappe.throw(_("Please enter a valid URL."))
def validate_status(self):
@@ -64,92 +66,15 @@ class LMSAssignmentSubmission(Document):
def trigger_update_notification(self):
notification = frappe._dict(
{
"subject": _("There has been an update on your submission for assignment {0}").format(
self.assignment_title
"subject": _("The instructor has left a comment on your assignment {0}").format(
frappe.bold(self.assignment_title)
),
"email_content": self.comments,
"document_type": self.doctype,
"document_name": self.name,
"for_user": self.owner,
"from_user": self.evaluator,
"type": "Alert",
"link": f"/assignment-submission/{self.assignment}/{self.name}",
"link": get_lms_route(f"assignment-submission/{self.assignment}/{self.name}"),
}
)
make_notification_logs(notification, [self.member])
@frappe.whitelist()
def upload_assignment(
assignment_attachment=None,
answer=None,
assignment=None,
lesson=None,
status="Not Graded",
comments=None,
submission=None,
):
if frappe.session.user == "Guest":
return
assignment_details = frappe.db.get_value(
"LMS Assignment", assignment, ["type", "grade_assignment"], as_dict=1
)
assignment_type = assignment_details.type
if assignment_type in ["URL", "Text"] and not answer:
frappe.throw(_("Please enter the URL for assignment submission."))
if assignment_type == "File" and not assignment_attachment:
frappe.throw(_("Please upload the assignment file."))
if assignment_type == "URL" and not validate_url(answer):
frappe.throw(_("Please enter a valid URL."))
if submission:
doc = frappe.get_doc("LMS Assignment Submission", submission)
else:
doc = frappe.get_doc(
{
"doctype": "LMS Assignment Submission",
"assignment": assignment,
"lesson": lesson,
"member": frappe.session.user,
"type": assignment_type,
}
)
doc.update(
{
"assignment_attachment": assignment_attachment,
"status": "Not Applicable"
if assignment_type == "Text" and not assignment_details.grade_assignment
else status,
"comments": comments,
"answer": answer,
}
)
doc.save(ignore_permissions=True)
return doc.name
@frappe.whitelist()
def get_assignment(lesson):
assignment = frappe.db.get_value(
"LMS Assignment Submission",
{"lesson": lesson, "member": frappe.session.user},
["name", "lesson", "member", "assignment_attachment", "comments", "status"],
as_dict=True,
)
assignment.file_name = frappe.db.get_value(
"File", {"file_url": assignment.assignment_attachment}, "file_name"
)
return assignment
@frappe.whitelist()
def grade_assignment(name, result, comments):
doc = frappe.get_doc("LMS Assignment Submission", name)
doc.status = result
doc.comments = comments
doc.save(ignore_permissions=True)

View File

@@ -5,7 +5,7 @@ frappe.ui.form.on("LMS Badge", {
refresh: (frm) => {
frm.events.set_field_options(frm);
if (frm.doc.event == "Auto Assign") {
if (frm.doc.event == "Manual Assignment" && frm.doc.enabled) {
add_assign_button(frm);
}
},
@@ -30,9 +30,7 @@ frappe.ui.form.on("LMS Badge", {
const user_fields = fields
.filter(
(df) =>
(df.fieldtype === "Link" && df.options === "User") ||
df.fieldtype === "Data"
(df) => df.fieldtype === "Link" && df.options === "User"
)
.map(map_for_options)
.concat([
@@ -51,11 +49,13 @@ const add_assign_button = (frm) => {
frappe.call({
method: "lms.lms.doctype.lms_badge.lms_badge.assign_badge",
args: {
badge: frm.doc,
badge_name: frm.doc.name,
},
callback: function (r) {
if (r.message) {
frappe.msgprint(r.message);
if (r.message == "success") {
frappe.toast(__("Badge assigned successfully"));
} else {
frappe.toast(__("Failed to assign badge"));
}
},
});

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "field:title",
"creation": "2024-04-30 11:29:53.548647",
@@ -51,14 +52,14 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Event",
"options": "New\nValue Change\nAuto Assign",
"options": "New\nValue Change\nManual Assignment",
"reqd": 1
},
{
"fieldname": "condition",
"fieldtype": "Code",
"label": "Condition",
"mandatory_depends_on": "eval:doc.event == \"Auto Assign\""
"reqd": 1
},
{
"depends_on": "eval:doc.event == 'Value Change'",
@@ -99,8 +100,8 @@
"link_fieldname": "badge"
}
],
"modified": "2025-07-04 13:02:19.048994",
"modified_by": "Administrator",
"modified": "2026-02-20 17:58:25.924109",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Badge",
"naming_rule": "By fieldname",
@@ -119,13 +120,17 @@
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "All",
"share": 1
"role": "Moderator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",

View File

@@ -10,7 +10,7 @@ from frappe.model.document import Document
class LMSBadge(Document):
def on_update(self):
if self.event == "Auto Assign" and self.condition:
if self.event == "Manual Assignment" and self.condition:
try:
json.loads(self.condition)
except ValueError:
@@ -54,6 +54,7 @@ def award(doc, member):
}
)
assignment.save()
return assignment.name
def eval_condition(doc, condition):
@@ -61,16 +62,30 @@ def eval_condition(doc, condition):
@frappe.whitelist()
def assign_badge(badge):
badge = frappe._dict(json.loads(badge))
if not badge.event == "Auto Assign":
def assign_badge(badge_name: str):
assignments = []
badge = frappe.db.get_value(
"LMS Badge",
badge_name,
["name", "event", "reference_doctype", "condition", "user_field"],
as_dict=True,
)
if not badge:
frappe.throw(_("Badge {0} not found").format(badge_name), frappe.DoesNotExistError)
if not badge.event == "Manual Assignment":
return
fields = ["name"]
fields.append(badge.user_field)
list = frappe.get_all(badge.reference_doctype, filters=badge.condition, fields=fields)
for doc in list:
award(badge, doc.get(badge.user_field))
docs = frappe.get_all(badge.reference_doctype, filters=json.loads(badge.condition), fields=fields)
for doc in docs:
assignment_name = award(badge, doc.get(badge.user_field))
if assignment_name:
assignments.append(assignment_name)
return "success" if assignments else "failed"
def process_badges(doc, state):

View File

@@ -84,7 +84,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-11-06 11:38:35.903520",
"modified": "2026-02-19 15:06:08.389081",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Badge Assignment",
@@ -116,25 +116,9 @@
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
"role": "LMS Student"
},
{
"create": 1,
@@ -166,5 +150,6 @@
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "member"
"title_field": "member",
"track_changes": 1
}

View File

@@ -1,9 +1,66 @@
# Copyright (c) 2024, Frappe and contributors
# For license information, please see license.txt
# import frappe
import frappe
from frappe import _
from frappe.model.document import Document
from lms.lms.doctype.lms_badge.lms_badge import eval_condition
class LMSBadgeAssignment(Document):
pass
def validate(self):
self.validate_duplicate_badge_assignment()
self.validate_badge_criteria()
self.validate_owner()
def validate_owner(self):
event = frappe.db.get_value("LMS Badge", self.badge, "event")
if event == "Manual Assignment":
roles = frappe.get_roles(frappe.session.user)
admins = ["Moderator", "Course Creator", "Batch Evaluator"]
if not any(role in roles for role in admins):
frappe.throw(_("You must be an Admin to assign badges to users."))
def validate_duplicate_badge_assignment(self):
grant_only_once = frappe.db.get_value("LMS Badge", self.badge, "grant_only_once")
if not grant_only_once:
return
if frappe.db.exists(
"LMS Badge Assignment",
{"badge": self.badge, "member": self.member, "name": ["!=", self.name]},
):
frappe.throw(
_("Badge {0} has already been assigned to this {1}.").format(self.badge, self.member)
)
def validate_badge_criteria(self):
badge_details = frappe.db.get_value(
"LMS Badge", self.badge, ["reference_doctype", "user_field", "condition", "enabled"], as_dict=True
)
if not badge_details:
return
if badge_details.reference_doctype and badge_details.user_field and badge_details.condition:
user_fieldname = frappe.db.get_value(
"DocField",
{"parent": badge_details.reference_doctype, "fieldname": badge_details.user_field},
"fieldname",
)
documents = frappe.get_all(
badge_details.reference_doctype,
{user_fieldname: self.member},
)
for document in documents:
reference_value = eval_condition(
frappe.get_doc(badge_details.reference_doctype, document.name),
badge_details.condition,
)
if reference_value:
return
frappe.throw(_("Member does not meet the criteria for the badge {0}.").format(self.badge))

View File

@@ -3,14 +3,6 @@
frappe.ui.form.on("LMS Batch", {
onload: function (frm) {
frm.set_query("student", "students", function (doc) {
return {
filters: {
ignore_user_type: 1,
},
};
});
frm.set_query("reference_doctype", "timetable", function () {
let doctypes = ["Course Lesson", "LMS Quiz", "LMS Assignment"];
return {
@@ -20,6 +12,14 @@ frappe.ui.form.on("LMS Batch", {
};
});
frm.set_query("course", "courses", function () {
return {
filters: {
published: 1,
},
};
});
frm.set_query("assessment_type", "assessment", function () {
let doctypes = ["LMS Quiz", "LMS Assignment"];
return {
@@ -48,8 +48,9 @@ frappe.ui.form.on("LMS Batch", {
},
refresh: (frm) => {
const lmsPath = frappe.boot.lms_path || "lms";
frm.add_web_link(
`/lms/batches/details/${frm.doc.name}`,
`/${lmsPath}/batches/${frm.doc.name}`,
"See on website"
);
},

View File

@@ -9,37 +9,43 @@
"engine": "InnoDB",
"field_order": [
"section_break_earo",
"published",
"title",
"start_date",
"end_date",
"column_break_4",
"allow_self_enrollment",
"start_time",
"end_time",
"timezone",
"section_break_wuxt",
"seat_count",
"column_break_uamg",
"category",
"section_break_cssv",
"published",
"evaluation",
"evaluation_end_date",
"column_break_wfkz",
"allow_self_enrollment",
"column_break_vnrp",
"certification",
"section_break_6",
"description",
"column_break_hlqw",
"instructors",
"zoom_account",
"column_break_hlqw",
"batch_details",
"section_break_rgfj",
"medium",
"category",
"confirmation_email_template",
"column_break_flwy",
"seat_count",
"evaluation_end_date",
"zoom_account",
"notification_sent",
"section_break_jedp",
"video_link",
"column_break_kpct",
"meta_image",
"section_break_khcn",
"batch_details",
"batch_details_raw",
"section_break_jgji",
"courses",
"section_break_khcn",
"batch_details_raw",
"assessment_tab",
"assessment",
"schedule_tab",
@@ -293,6 +299,7 @@
"label": "Allow accessing future dates"
},
{
"depends_on": "evaluation",
"fieldname": "evaluation_end_date",
"fieldtype": "Date",
"label": "Evaluation End Date"
@@ -337,10 +344,6 @@
"fieldname": "column_break_wfkz",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_vnrp",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "certification",
@@ -354,13 +357,48 @@
},
{
"fieldname": "section_break_cssv",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"label": "Certification"
},
{
"fieldname": "zoom_account",
"fieldtype": "Link",
"label": "Zoom Account",
"options": "LMS Zoom Settings"
},
{
"fieldname": "video_link",
"fieldtype": "Attach",
"label": "Preview Video"
},
{
"default": "0",
"fieldname": "notification_sent",
"fieldtype": "Check",
"label": "Notification Sent",
"read_only": 1
},
{
"fieldname": "section_break_jedp",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_kpct",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "evaluation",
"fieldtype": "Check",
"label": "Evaluation"
},
{
"fieldname": "section_break_wuxt",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_uamg",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
@@ -377,9 +415,13 @@
{
"link_doctype": "LMS Certificate",
"link_fieldname": "batch_name"
},
{
"link_doctype": "LMS Payment",
"link_fieldname": "payment_for_document"
}
],
"modified": "2025-05-26 15:30:55.083507",
"modified": "2026-02-13 14:23:51.913875",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Batch",
@@ -422,13 +464,8 @@
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
"role": "LMS Student"
}
],
"row_format": "Dynamic",

View File

@@ -8,15 +8,19 @@ from datetime import timedelta
import frappe
import requests
from frappe import _
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
from frappe.model.document import Document
from frappe.utils import add_days, cint, format_datetime, get_time, nowdate
from lms.lms.utils import (
generate_slug,
get_assignment_details,
get_instructors,
get_lesson_index,
get_lesson_url,
get_lms_route,
get_quiz_details,
guest_access_allowed,
update_payment_record,
)
@@ -30,10 +34,13 @@ class LMSBatch(Document):
self.validate_payments_app()
self.validate_amount_and_currency()
self.validate_duplicate_assessments()
self.validate_membership()
self.validate_timetable()
self.validate_evaluation_end_date()
def on_update(self):
if self.has_value_changed("published") and self.published:
frappe.enqueue(send_notification_for_published_batch, batch=self)
def autoname(self):
if not self.name:
self.name = generate_slug(self.title, "LMS Batch")
@@ -82,16 +89,6 @@ class LMSBatch(Document):
if self.evaluation_end_date and self.evaluation_end_date < self.end_date:
frappe.throw(_("Evaluation end date cannot be less than the batch end date."))
def validate_membership(self):
members = frappe.get_all("LMS Batch Enrollment", {"batch": self.name}, pluck="member")
for course in self.courses:
for member in members:
if not frappe.db.exists("LMS Enrollment", {"course": course.course, "member": member}):
enrollment = frappe.new_doc("LMS Enrollment")
enrollment.course = course.course
enrollment.member = member
enrollment.save()
def validate_seats_left(self):
if cint(self.seat_count) < 0:
frappe.throw(_("Seat count cannot be negative."))
@@ -134,18 +131,93 @@ class LMSBatch(Document):
update_payment_record("LMS Batch", self.name)
def send_notification_for_published_batch(batch):
send_notification = frappe.db.get_single_value("LMS Settings", "send_notification_for_published_batches")
if not send_notification:
return
if not batch.published:
return
if batch.notification_sent:
return
if send_notification == "Email":
send_email_notification_for_published_batch(batch)
else:
send_system_notification_for_published_batch(batch)
def send_email_notification_for_published_batch(batch):
brand_name = frappe.db.get_single_value("Website Settings", "app_name")
brand_logo = frappe.db.get_single_value("Website Settings", "banner_image")
subject = _("A new course has been published on {0}").format(brand_name)
template = "published_batch_notification"
students = frappe.get_all("User", {"enabled": 1}, pluck="name")
instructors = get_instructors("LMS Batch", batch.name)
args = {
"brand_logo": brand_logo,
"brand_name": brand_name,
"title": batch.title,
"short_introduction": batch.description,
"start_date": batch.start_date,
"end_date": batch.end_date,
"start_time": batch.start_time,
"medium": batch.medium,
"timezone": batch.timezone,
"instructors": instructors,
"batch_url": frappe.utils.get_url(get_lms_route(f"batches/{batch.name}")),
}
frappe.sendmail(
recipients=instructors,
bcc=students,
subject=subject,
template=template,
args=args,
)
frappe.db.set_value("LMS Batch", batch.name, "notification_sent", 1)
def send_system_notification_for_published_batch(batch):
students = frappe.get_all("User", {"enabled": 1}, pluck="name")
instructors = frappe.get_all("Course Instructor", {"parent": batch.name}, pluck="instructor")
instructor_name = frappe.db.get_value("User", instructors[0], "full_name")
notification = frappe._dict(
{
"subject": _("{0} has published a new batch {1}").format(
frappe.bold(instructor_name), frappe.bold(batch.title)
),
"email_content": _(
"A new batch '{0}' has been published that might interest you. Check it out!"
).format(batch.title),
"document_type": "LMS Batch",
"document_name": batch.name,
"from_user": instructors[0] if instructors else None,
"type": "Alert",
"link": get_lms_route(f"batches/{batch.name}"),
}
)
make_notification_logs(notification, students)
frappe.db.set_value("LMS Batch", batch.name, "notification_sent", 1)
@frappe.whitelist()
def create_live_class(
batch_name,
zoom_account,
title,
duration,
date,
time,
timezone,
auto_recording,
description=None,
batch_name: str,
zoom_account: str,
title: str,
duration: int,
date: str,
time: str,
timezone: str,
auto_recording: str,
description: str = None,
):
roles = frappe.get_roles()
if not any(role in roles for role in ["Moderator", "Batch Evaluator"]):
frappe.throw(_("You do not have permission to create a live class."))
payload = {
"topic": title,
"start_time": format_datetime(f"{date} {time}", "yyyy-MM-ddTHH:mm:ssZ"),
@@ -213,7 +285,7 @@ def authenticate(zoom_account):
@frappe.whitelist()
def get_batch_timetable(batch):
def get_batch_timetable(batch: str):
timetable = frappe.get_all(
"LMS Batch Timetable",
filters={"parent": batch},
@@ -324,3 +396,26 @@ def send_mail(batch, student):
args=args,
header=[_(f"Batch Start Reminder: {batch.title}"), "orange"],
)
def has_permission(doc, ptype="read", user=None):
user = user or frappe.session.user
if user == "Guest" and not guest_access_allowed():
return False
roles = frappe.get_roles(user)
if "Moderator" in roles or "Batch Evaluator" in roles:
return True
if ptype not in ("read", "select", "print"):
return False
is_enrolled = frappe.db.exists("LMS Batch Enrollment", {"batch": doc.name, "member": user})
if is_enrolled:
return True
is_batch_published = frappe.db.get_value("LMS Batch", doc.name, "published")
if is_batch_published:
return True
return False

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"creation": "2025-02-10 11:17:12.462368",
"doctype": "DocType",
@@ -8,6 +9,7 @@
"member",
"member_name",
"member_username",
"member_image",
"column_break_sjzm",
"batch",
"payment",
@@ -69,12 +71,18 @@
"label": "Batch",
"options": "LMS Batch",
"reqd": 1
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach Image",
"label": "Member Image"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-02-11 10:39:57.259526",
"modified_by": "Administrator",
"modified": "2026-02-10 16:07:28.315982",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Batch Enrollment",
"owner": "Administrator",
@@ -96,6 +104,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -105,18 +114,27 @@
},
{
"create": 1,
"if_owner": 1,
"read": 1,
"role": "LMS Student"
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
"role": "Batch Evaluator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "member_name"
}
}

View File

@@ -15,9 +15,51 @@ class LMSBatchEnrollment(Document):
self.add_member_to_live_class()
def validate(self):
self.validate_owner()
self.validate_duplicate_members()
self.validate_payment()
self.validate_self_enrollment()
self.validate_seat_availability()
self.validate_course_enrollment()
def validate_owner(self):
if self.owner == self.member:
return
roles = frappe.get_roles()
if "Moderator" not in roles and "Batch Evaluator" not in roles:
frappe.throw(_("You must be a Moderator or Batch Evaluator to enroll users in a batch."))
def validate_payment(self):
paid_batch = frappe.db.get_value("LMS Batch", self.batch, "paid_batch")
if paid_batch:
payment = frappe.db.exists(
"LMS Payment",
{
"payment_for_document_type": "LMS Batch",
"payment_for_document": self.batch,
"member": self.member,
"payment_received": True,
},
)
if not payment:
frappe.throw(_("Payment is required to enroll in this batch."))
else:
self.payment = payment
def validate_self_enrollment(self):
batch_details = frappe.db.get_value(
"LMS Batch", self.batch, ["allow_self_enrollment", "paid_batch"], as_dict=True
)
if batch_details.paid_batch:
return
if not batch_details.allow_self_enrollment and not self.is_admin():
frappe.throw(_("Enrollment in this batch is restricted. Please contact the Administrator."))
def is_admin(self):
roles = frappe.get_roles(frappe.session.user)
return "Course Creator" in roles or "Moderator" in roles or "Batch Evaluator" in roles
def validate_duplicate_members(self):
if frappe.db.exists(
"LMS Batch Enrollment",
@@ -25,6 +67,12 @@ class LMSBatchEnrollment(Document):
):
frappe.throw(_("Member already enrolled in this batch"))
def validate_seat_availability(self):
seat_count = frappe.db.get_value("LMS Batch", self.batch, "seat_count")
enrolled_count = frappe.db.count("LMS Batch Enrollment", {"batch": self.batch})
if seat_count and enrolled_count >= seat_count:
frappe.throw(_("There are no seats available in this batch."))
def validate_course_enrollment(self):
courses = frappe.get_all("Batch Course", filters={"parent": self.batch}, fields=["course"])
@@ -36,6 +84,7 @@ class LMSBatchEnrollment(Document):
enrollment = frappe.new_doc("LMS Enrollment")
enrollment.course = course.course
enrollment.member = self.member
enrollment.enrollment_from_batch = self.batch
enrollment.save()
def add_member_to_live_class(self):
@@ -57,7 +106,7 @@ class LMSBatchEnrollment(Document):
@frappe.whitelist()
def send_confirmation_email(doc):
def send_confirmation_email(doc: Document):
if isinstance(doc, str):
doc = frappe._dict(json.loads(doc))

View File

@@ -76,7 +76,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-05-21 15:58:51.667270",
"modified": "2026-01-14 08:53:38.088168",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Batch Feedback",
@@ -105,6 +105,30 @@
"role": "LMS Student",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"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
}
],
"row_format": "Dynamic",

View File

@@ -1,7 +0,0 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on("LMS Batch Old", {
// refresh: function(frm) {
// }
});

View File

@@ -1,150 +0,0 @@
{
"actions": [],
"autoname": "format: BATCH-{#####}",
"creation": "2021-03-18 19:37:34.614796",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"course",
"start_date",
"start_time",
"column_break_3",
"title",
"sessions_on",
"end_time",
"section_break_5",
"description",
"section_break_7",
"visibility",
"membership",
"column_break_9",
"status",
"stage"
],
"fields": [
{
"fieldname": "course",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Course",
"options": "LMS Course",
"reqd": 1
},
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Title",
"reqd": 1
},
{
"fieldname": "description",
"fieldtype": "Markdown Editor",
"label": "Description"
},
{
"default": "Public",
"fieldname": "visibility",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Visibility",
"options": "Public\nUnlisted\nPrivate"
},
{
"fieldname": "membership",
"fieldtype": "Select",
"label": "Membership",
"options": "\nOpen\nRestricted\nInvite Only\nClosed"
},
{
"default": "Active",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "Active\nInactive"
},
{
"default": "Ready",
"fieldname": "stage",
"fieldtype": "Select",
"label": "Stage",
"options": "Ready\nIn Progress\nCompleted\nCancelled"
},
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_5",
"fieldtype": "Section Break",
"label": "Batch Description"
},
{
"fieldname": "column_break_9",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_7",
"fieldtype": "Section Break",
"label": "Batch Settings"
},
{
"fieldname": "start_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Start Date"
},
{
"fieldname": "start_time",
"fieldtype": "Time",
"in_list_view": 1,
"label": "Start Time"
},
{
"fieldname": "sessions_on",
"fieldtype": "Data",
"label": "Sessions On Days"
},
{
"fieldname": "end_time",
"fieldtype": "Time",
"in_list_view": 1,
"label": "End Time"
}
],
"index_web_pages_for_search": 1,
"links": [
{
"group": "Members",
"link_doctype": "LMS Enrollment",
"link_fieldname": "batch_old"
}
],
"modified": "2022-09-28 18:43:22.955907",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Batch Old",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -1,90 +0,0 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
from lms.lms.doctype.lms_enrollment.lms_enrollment import create_membership
from lms.lms.utils import is_mentor
class LMSBatchOld(Document):
def validate(self):
pass
# self.validate_if_mentor()
def validate_if_mentor(self):
if not is_mentor(self.course, frappe.session.user):
course_title = frappe.db.get_value("LMS Course", self.course, "title")
frappe.throw(_("You are not a mentor of the course {0}").format(course_title))
def after_insert(self):
create_membership(batch=self.name, course=self.course, member_type="Mentor")
def is_member(self, email, member_type=None):
"""Checks if a person is part of a batch.
If member_type is specified, checks if the person is a Student/Mentor.
"""
filters = {"batch_old": self.name, "member": email}
if member_type:
filters["member_type"] = member_type
return frappe.db.exists("LMS Enrollment", filters)
def get_membership(self, email):
"""Returns the membership document of given user."""
name = frappe.get_value(
doctype="LMS Enrollment",
filters={"batch_old": self.name, "member": email},
fieldname="name",
)
return frappe.get_doc("LMS Enrollment", name)
def get_current_lesson(self, user):
"""Returns the name of the current lesson for the given user."""
membership = self.get_membership(user)
return membership and membership.current_lesson
@frappe.whitelist()
def save_message(message, batch):
doc = frappe.get_doc(
{
"doctype": "LMS Message",
"batch_old": batch,
"author": frappe.session.user,
"message": message,
}
)
doc.save(ignore_permissions=True)
def switch_batch(course_name, email, batch_name):
"""Switches the user from the current batch of the course to a new batch."""
membership = frappe.get_last_doc("LMS Enrollment", filters={"course": course_name, "member": email})
batch = frappe.get_doc("LMS Batch Old", batch_name)
if not batch:
raise ValueError(f"Invalid Batch: {batch_name}")
if batch.course != course_name:
raise ValueError("Can not switch batches across courses")
if batch.is_member(email):
print(f"{email} is already a member of {batch.title}")
return
old_batch = frappe.get_doc("LMS Batch Old", membership.batch_old)
membership.batch_old = batch_name
membership.save()
# update exercise submissions
filters = {"owner": email, "batch_old": old_batch.name}
for name in frappe.db.get_all("Exercise Submission", filters=filters, pluck="name"):
doc = frappe.get_doc("Exercise Submission", name)
print("updating exercise submission", name)
doc.batch_old = batch_name
doc.save()

View File

@@ -1,9 +0,0 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
class TestLMSBatchOld(unittest.TestCase):
pass

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "field:category",
"creation": "2023-06-15 12:40:36.484165",
@@ -21,8 +22,8 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-03-19 12:12:23.723432",
"modified_by": "Administrator",
"modified": "2025-11-08 19:28:28.468137",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Category",
"naming_rule": "By fieldname",
@@ -73,9 +74,10 @@
"share": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "category",
"track_changes": 1
}
}

View File

@@ -123,7 +123,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-10-07 19:24:12.272810",
"modified": "2026-02-20 17:32:34.580862",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Certificate",
@@ -155,15 +155,36 @@
},
{
"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
},
{
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1,
"write": 1
"share": 1
}
],
"row_format": "Dynamic",

View File

@@ -6,25 +6,28 @@ from frappe import _
from frappe.email.doctype.email_template.email_template import get_email_template
from frappe.model.document import Document
from frappe.model.naming import make_autoname
from frappe.utils import add_years, nowdate
from lms.lms.utils import is_certified
from frappe.utils import nowdate
from frappe.utils.telemetry import capture
class LMSCertificate(Document):
def validate(self):
self.validate_criteria()
self.validate_duplicate_certificate()
def autoname(self):
self.name = make_autoname("hash", self.doctype)
def after_insert(self):
if not frappe.flags.in_test:
outgoing_email_account = frappe.get_cached_value(
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
)
if outgoing_email_account or frappe.conf.get("mail_login"):
self.send_mail()
capture("certificate_issued", "lms")
self.send_certification_email()
def send_certification_email(self):
outgoing_email_account = frappe.get_cached_value(
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
)
if outgoing_email_account or frappe.conf.get("mail_login"):
self.send_mail()
def send_mail(self):
subject = _("Congratulations on getting certified!")
@@ -52,6 +55,43 @@ class LMSCertificate(Document):
header=[subject, "green"],
)
def validate_criteria(self):
self.validate_role_of_owner()
self.validate_batch_enrollment()
self.validate_course_enrollment()
def validate_role_of_owner(self):
roles = frappe.get_roles()
is_admin = any(role in roles for role in ["Moderator", "Course Creator", "Batch Evaluator"])
if not self.course and not self.batch_name and not is_admin:
frappe.throw(_("Course or Batch is required to issue a certificate."))
def validate_batch_enrollment(self):
if self.batch_name:
is_enrolled = frappe.db.exists(
"LMS Batch Enrollment", {"batch": self.batch_name, "member": self.member}
)
if not is_enrolled:
frappe.throw(_("Certification cannot be issued as the member is not enrolled in this batch."))
def validate_course_enrollment(self):
if self.course:
is_enrolled = frappe.db.exists("LMS Enrollment", {"course": self.course, "member": self.member})
if not is_enrolled:
frappe.throw(
_("Certification cannot be issued as the member is not enrolled in this course.")
)
completion_certificate = frappe.db.get_value("LMS Course", self.course, "enable_certification")
if completion_certificate:
progress = frappe.db.get_value(
"LMS Enrollment", {"course": self.course, "member": self.member}, "progress"
)
if progress < 100:
frappe.throw(
_("Certification cannot be issued as the member has not completed the course.")
)
def validate_duplicate_certificate(self):
self.validate_course_duplicates()
self.validate_batch_duplicates()
@@ -113,31 +153,23 @@ def has_website_permission(doc, ptype, user, verbose=False):
return False
@frappe.whitelist()
def create_certificate(course):
certificate = is_certified(course)
def is_certified(course):
certificate = frappe.get_all("LMS Certificate", {"member": frappe.session.user, "course": course})
if len(certificate):
return certificate[0].name
return
if certificate:
@frappe.whitelist()
def create_certificate(course: str):
if is_certified(course):
return frappe.db.get_value(
"LMS Certificate", certificate, ["name", "course", "template"], as_dict=True
)
else:
default_certificate_template = frappe.db.get_value(
"Property Setter",
{
"doc_type": "LMS Certificate",
"property": "default_print_format",
},
"value",
)
if not default_certificate_template:
default_certificate_template = frappe.db.get_value(
"Print Format",
{
"doc_type": "LMS Certificate",
},
)
validate_certification_eligibility(course)
default_certificate_template = get_default_certificate_template()
certificate = frappe.get_doc(
{
"doctype": "LMS Certificate",
@@ -149,3 +181,57 @@ def create_certificate(course):
)
certificate.save(ignore_permissions=True)
return certificate
def get_default_certificate_template():
default_certificate_template = frappe.db.get_value(
"Property Setter",
{
"doc_type": "LMS Certificate",
"property": "default_print_format",
},
"value",
)
if not default_certificate_template:
default_certificate_template = frappe.db.get_value(
"Print Format",
{
"doc_type": "LMS Certificate",
},
)
return default_certificate_template
def validate_certification_eligibility(course):
if not frappe.db.exists("LMS Enrollment", {"course": course, "member": frappe.session.user}):
frappe.throw(_("You are not enrolled in this course."))
if not frappe.db.get_value("LMS Course", course, "enable_certification"):
frappe.throw(_("Certification is not enabled for this course."))
progress = frappe.db.get_value(
"LMS Enrollment", {"course": course, "member": frappe.session.user}, "progress"
)
if progress < 100:
frappe.throw(_("You have not completed the course yet."))
def has_permission(doc, ptype="read", user=None):
user = user or frappe.session.user
roles = frappe.get_roles(user)
if "Moderator" in roles or "Course Creator" in roles or "Batch Evaluator" in roles:
return True
if doc.owner == user:
return True
if ptype not in ("read", "select", "print"):
return False
return doc.published
def get_permission_query_conditions(user):
user = user or frappe.session.user
roles = frappe.get_roles(user)
if "Moderator" in roles or "Course Creator" in roles or "Batch Evaluator" in roles:
return None
return """(`tabLMS Certificate`.published = 1)"""

View File

@@ -3,26 +3,6 @@
import unittest
import frappe
from frappe.utils import add_years, cint, nowdate
from lms.lms.doctype.lms_certificate.lms_certificate import create_certificate
from lms.lms.doctype.lms_course.test_lms_course import new_course
class TestLMSCertificate(unittest.TestCase):
def test_certificate_creation(self):
course = new_course(
"Test Certificate",
{
"enable_certification": 1,
},
)
certificate = create_certificate(course.name)
self.assertEqual(certificate.member, "Administrator")
self.assertEqual(certificate.course, course.name)
self.assertEqual(certificate.issue_date, nowdate())
frappe.db.delete("LMS Certificate", certificate.name)
frappe.db.delete("LMS Course", course.name)
pass

View File

@@ -14,15 +14,6 @@ frappe.ui.form.on("LMS Certificate Evaluation", {
},
onload: function (frm) {
frm.set_query("course", function (doc) {
return {
filters: {
enable_certification: true,
grant_certificate_after: "Evaluation",
},
};
});
frm.set_query("member", function (doc) {
return {
filters: {

View File

@@ -131,10 +131,11 @@
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-09-11 11:20:06.233491",
"modified_by": "Administrator",
"modified": "2025-11-10 11:41:38.999620",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Certificate Evaluation",
"owner": "Administrator",
@@ -164,6 +165,7 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [
@@ -184,5 +186,6 @@
"title": "In Progress"
}
],
"title_field": "member_name"
}
"title_field": "member_name",
"track_changes": 1
}

View File

@@ -6,7 +6,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from lms.lms.utils import has_course_moderator_role
from lms.lms.utils import has_moderator_role
class LMSCertificateEvaluation(Document):
@@ -19,13 +19,13 @@ class LMSCertificateEvaluation(Document):
def has_website_permission(doc, ptype, user, verbose=False):
if has_course_moderator_role() or doc.member == frappe.session.user:
if has_moderator_role() or doc.member == frappe.session.user:
return True
return False
@frappe.whitelist()
def create_lms_certificate(source_name, target_doc=None):
def create_lms_certificate(source_name: str, target_doc: dict = None):
doc = get_mapped_doc(
"LMS Certificate Evaluation",
source_name,

View File

@@ -157,7 +157,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-10-13 14:30:57.897102",
"modified": "2026-02-23 14:45:44.994705",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Certificate Request",
@@ -192,6 +192,7 @@
"create": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -209,6 +210,18 @@
"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
}
],
"row_format": "Dynamic",
@@ -228,5 +241,6 @@
"title": "Cancelled"
}
],
"title_field": "member_name"
"title_field": "member_name",
"track_changes": 1
}

View File

@@ -13,6 +13,7 @@ from frappe.utils import (
format_time,
get_datetime,
get_fullname,
get_system_timezone,
get_time,
getdate,
nowtime,
@@ -67,6 +68,7 @@ class LMSCertificateRequest(Document):
{
"evaluator": self.evaluator,
"date": self.date,
"status": ["!=", "Cancelled"],
"start_time": self.start_time,
"member": ["!=", self.member],
},
@@ -117,16 +119,7 @@ class LMSCertificateRequest(Document):
def validate_timezone(self):
if self.timezone:
return
if self.batch_name:
timezone = frappe.db.get_value("LMS Batch", self.batch_name, "timezone")
if timezone:
self.timezone = timezone
return
if self.course:
timezone = frappe.db.get_value("LMS Course", self.course, "timezone")
if timezone:
self.timezone = timezone
return
self.timezone = get_system_timezone()
def send_notification(self):
outgoing_email_account = frappe.get_cached_value(
@@ -173,7 +166,7 @@ def schedule_evals():
@frappe.whitelist()
def setup_calendar_event(eval):
def setup_calendar_event(eval: str):
if isinstance(eval, str):
eval = frappe._dict(json.loads(eval))
@@ -185,7 +178,7 @@ def setup_calendar_event(eval):
update_meeting_details(eval, event, calendar)
def create_event(eval):
def create_event(eval: dict):
event = frappe.get_doc(
{
"doctype": "Event",
@@ -198,7 +191,7 @@ def create_event(eval):
return event
def add_participants(eval, event):
def add_participants(eval: dict, event: Document):
participants = [eval.member, eval.evaluator]
for participant in participants:
contact_name = frappe.db.get_value("Contact", {"email_id": participant}, "name")
@@ -215,7 +208,7 @@ def add_participants(eval, event):
).save()
def update_meeting_details(eval, event, calendar):
def update_meeting_details(eval: dict, event: Document, calendar: str):
event.reload()
event.update(
{
@@ -231,31 +224,8 @@ def update_meeting_details(eval, event, calendar):
@frappe.whitelist()
def create_certificate_request(course, date, day, start_time, end_time, batch_name=None):
is_member = frappe.db.exists(
{"doctype": "LMS Enrollment", "course": course, "member": frappe.session.user}
)
if not is_member:
return
eval = frappe.new_doc("LMS Certificate Request")
eval.update(
{
"course": course,
"evaluator": get_evaluator(course, batch_name),
"member": frappe.session.user,
"date": date,
"day": day,
"start_time": start_time,
"end_time": end_time,
"batch_name": batch_name,
}
)
eval.save(ignore_permissions=True)
@frappe.whitelist()
def create_lms_certificate_evaluation(source_name, target_doc=None):
def create_lms_certificate_evaluation(source_name: str, target_doc: dict = None):
frappe.only_for(["Moderator", "Batch Evaluator", "System Manager"])
doc = get_mapped_doc(
"LMS Certificate Request",
source_name,

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,194 @@
{
"actions": [],
"allow_import": 1,
"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": "2026-02-03 10:50:23.387175",
"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,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"import": 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

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

View File

@@ -20,7 +20,11 @@ frappe.ui.form.on("LMS Course", {
});
},
refresh: (frm) => {
frm.add_web_link(`/lms/courses/${frm.doc.name}`, "See on Website");
const lmsPath = frappe.boot.lms_path || "lms";
frm.add_web_link(
`/${lmsPath}/courses/${frm.doc.name}`,
"See on Website"
);
if (!frm.doc.currency)
frappe.db

View File

@@ -1,12 +1,5 @@
{
"actions": [
{
"action": "lms.lms.doctype.lms_course.lms_course.reindex_exercises",
"action_type": "Server Action",
"group": "Reindex",
"label": "Reindex Exercises"
}
],
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"creation": "2022-02-22 15:28:26.091549",
@@ -41,9 +34,9 @@
"pricing_tab",
"pricing_section",
"paid_course",
"enable_certification",
"paid_certificate",
"column_break_acoj",
"enable_certification",
"section_break_vqbh",
"course_price",
"currency",
@@ -55,7 +48,8 @@
"statistics_section",
"enrollments",
"lessons",
"rating"
"rating",
"notification_sent"
],
"fields": [
{
@@ -76,6 +70,8 @@
"default": "0",
"fieldname": "published",
"fieldtype": "Check",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Published"
},
{
@@ -152,8 +148,6 @@
"fieldname": "status",
"fieldtype": "Select",
"hidden": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Status",
"options": "In Progress\nUnder Review\nApproved",
"read_only": 1
@@ -174,7 +168,7 @@
"fieldname": "currency",
"fieldtype": "Link",
"label": "Currency",
"mandatory_depends_on": "paid_course",
"mandatory_depends_on": "eval: doc.paid_course || doc.paid_certificate",
"options": "Currency"
},
{
@@ -187,7 +181,7 @@
"fieldname": "course_price",
"fieldtype": "Currency",
"label": "Amount",
"mandatory_depends_on": "paid_course"
"mandatory_depends_on": "eval: doc.paid_course || doc.paid_certificate"
},
{
"fieldname": "column_break_acoj",
@@ -295,6 +289,13 @@
"fieldname": "timezone",
"fieldtype": "Data",
"label": "Timezone"
},
{
"default": "0",
"fieldname": "notification_sent",
"fieldtype": "Check",
"label": "Notification Sent",
"read_only": 1
}
],
"is_published_field": "published",
@@ -313,7 +314,7 @@
}
],
"make_attachments_public": 1,
"modified": "2025-10-13 15:08:11.734204",
"modified": "2026-02-19 11:41:57.038869",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Course",
@@ -336,6 +337,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -348,6 +350,7 @@
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,

Some files were not shown because too many files have changed in this diff Show More