Merge remote-tracking branch 'origin/develop' into feat/scorm-progress

This commit is contained in:
Fahid Latheef Alungal
2025-09-07 02:00:32 +05:30
451 changed files with 155495 additions and 39845 deletions

View File

@@ -1 +1 @@
__version__ = "2.24.0"
__version__ = "2.34.1"

View File

@@ -21,7 +21,7 @@ app_license = "AGPL"
# include js, css files in header of web template
web_include_css = "lms.bundle.css"
# web_include_css = "/assets/lms/css/lms.css"
web_include_js = ["website.bundle.js"]
web_include_js = []
# include custom scss in every website theme (without file extension ".scss")
# website_theme_scss = "lms/public/scss/website"
@@ -88,7 +88,6 @@ setup_wizard_requires = "assets/lms/js/setup_wizard.js"
# Override standard doctype classes
override_doctype_class = {
"User": "lms.overrides.user.CustomUser",
"Web Template": "lms.overrides.web_template.CustomWebTemplate",
}
@@ -104,6 +103,10 @@ doc_events = {
},
"Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"},
"Notification Log": {"on_change": "lms.lms.utils.publish_notifications"},
"User": {
"validate": "lms.lms.user.validate_username_duplicates",
"after_insert": "lms.lms.user.after_insert",
},
}
# Scheduled Tasks
@@ -112,6 +115,8 @@ scheduler_events = {
"hourly": [
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals",
"lms.lms.api.update_course_statistics",
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.mark_eval_as_completed",
"lms.lms.doctype.lms_live_class.lms_live_class.update_attendance",
],
"daily": [
"lms.job.doctype.job_opportunity.job_opportunity.update_job_openings",
@@ -189,9 +194,8 @@ jinja = {
"lms.lms.utils.get_instructors",
"lms.lms.utils.get_lesson_index",
"lms.lms.utils.get_lesson_url",
"lms.page_renderers.get_profile_url",
"lms.overrides.user.get_palette",
"lms.lms.utils.is_instructor",
"lms.lms.utils.get_palette",
],
"filters": [],
}
@@ -225,11 +229,7 @@ lms_markdown_macro_renderers = {
"PDF": "lms.plugins.pdf_renderer",
}
# page_renderer to manage profile pages
page_renderer = [
"lms.page_renderers.ProfileRedirectPage",
"lms.page_renderers.ProfilePage",
"lms.page_renderers.CoursePage",
"lms.page_renderers.SCORMRenderer",
]
@@ -238,12 +238,12 @@ profile_url_prefix = "/users/"
signup_form_template = "lms.plugins.show_custom_signup"
on_session_creation = "lms.overrides.user.on_session_creation"
on_login = "lms.lms.user.on_login"
add_to_apps_screen = [
{
"name": "lms",
"logo": "/assets/lms/images/lms-logo.png",
"logo": "/assets/lms/frontend/learning.svg",
"title": "Learning",
"route": "/lms",
"has_permission": "lms.lms.api.check_app_permission",

View File

@@ -1,49 +1,18 @@
import frappe
from frappe.desk.page.setup_wizard.setup_wizard import add_all_roles_to
from lms.lms.api import give_dicussions_permission
from lms.lms.api import give_discussions_permission
def after_install():
add_pages_to_nav()
create_batch_source()
give_dicussions_permission()
give_discussions_permission()
def after_sync():
create_lms_roles()
set_default_certificate_print_format()
add_all_roles_to("Administrator")
def add_pages_to_nav():
pages = [
{"label": "Explore", "idx": 1},
{"label": "Courses", "url": "/lms/courses", "parent": "Explore", "idx": 2},
{"label": "Batches", "url": "/lms/batches", "parent": "Explore", "idx": 3},
{"label": "Statistics", "url": "/lms/statistics", "parent": "Explore", "idx": 4},
{"label": "Jobs", "url": "/lms/job-openings", "parent": "Explore", "idx": 5},
]
for page in pages:
filters = frappe._dict()
if page.get("url"):
filters["url"] = ["like", "%" + page.get("url") + "%"]
else:
filters["label"] = page.get("label")
if not frappe.db.exists("Top Bar Item", filters):
frappe.get_doc(
{
"doctype": "Top Bar Item",
"label": page.get("label"),
"url": page.get("url"),
"parent_label": page.get("parent"),
"idx": page.get("idx"),
"parent": "Website Settings",
"parenttype": "Website Settings",
"parentfield": "top_bar_items",
}
).save()
give_lms_roles_to_admin()
def before_uninstall():
@@ -204,3 +173,15 @@ def create_batch_source():
doc = frappe.new_doc("LMS Source")
doc.source = source
doc.save()
def give_lms_roles_to_admin():
roles = ["Course Creator", "Moderator", "Batch Evaluator"]
for role in roles:
if not frappe.db.exists("Has Role", {"parent": "Administrator", "role": role}):
doc = frappe.new_doc("Has Role")
doc.parent = "Administrator"
doc.parenttype = "User"
doc.parentfield = "roles"
doc.role = role
doc.save()

View File

@@ -9,18 +9,19 @@
"field_order": [
"job_title",
"location",
"disabled",
"country",
"column_break_5",
"type",
"status",
"disabled",
"section_break_6",
"description",
"company_details_section",
"company_name",
"company_website",
"column_break_11",
"column_break_phkm",
"company_logo",
"company_email_address"
"company_email_address",
"company_details_section",
"description"
],
"fields": [
{
@@ -36,7 +37,7 @@
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Location",
"label": "City",
"reqd": 1
},
{
@@ -62,7 +63,8 @@
},
{
"fieldname": "section_break_6",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"label": "Company Details"
},
{
"fieldname": "description",
@@ -72,8 +74,7 @@
},
{
"fieldname": "company_details_section",
"fieldtype": "Section Break",
"label": "Company Details"
"fieldtype": "Section Break"
},
{
"fieldname": "company_name",
@@ -89,10 +90,6 @@
"label": "Company Website",
"reqd": 1
},
{
"fieldname": "column_break_11",
"fieldtype": "Column Break"
},
{
"fieldname": "company_logo",
"fieldtype": "Attach Image",
@@ -111,13 +108,30 @@
"label": "Company Email Address",
"options": "Email",
"reqd": 1
},
{
"fieldname": "column_break_phkm",
"fieldtype": "Column Break"
},
{
"fieldname": "country",
"fieldtype": "Link",
"label": "Country",
"options": "Country",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"links": [
{
"link_doctype": "LMS Job Application",
"link_fieldname": "job"
}
],
"make_attachments_public": 1,
"modified": "2025-01-17 12:38:57.134919",
"modified_by": "Administrator",
"modified": "2025-04-24 14:34:35.920242",
"modified_by": "sayali@frappe.io",
"module": "Job",
"name": "Job Opportunity",
"owner": "Administrator",
@@ -157,8 +171,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "job_title"
}
}

View File

@@ -4,9 +4,10 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import get_link_to_form, add_months, getdate
from frappe.utils import add_months, get_link_to_form, getdate
from frappe.utils.user import get_system_managers
from lms.lms.utils import validate_image, generate_slug
from lms.lms.utils import generate_slug, validate_image
class JobOpportunity(Document):

View File

@@ -1,37 +1,39 @@
"""API methods for the LMS.
"""
"""API methods for the LMS."""
import json
import frappe
import zipfile
import os
import re
import shutil
import xml.etree.ElementTree as ET
from frappe.translate import get_all_translations
from frappe import _
from frappe.utils import (
get_datetime,
getdate,
cint,
flt,
now,
add_days,
format_date,
date_diff,
)
from lms.lms.utils import get_average_rating, get_lesson_count
import zipfile
from xml.dom.minidom import parseString
import frappe
from frappe import _
from frappe.integrations.frappe_providers.frappecloud_billing import (
current_site_info,
is_fc_site,
)
from frappe.query_builder import DocType
from frappe.translate import get_all_translations
from frappe.utils import (
add_days,
cint,
date_diff,
flt,
format_date,
get_datetime,
now,
)
from lms.lms.doctype.course_lesson.course_lesson import save_progress
from frappe.integrations.frappe_providers.frappecloud_billing import is_fc_site
from lms.lms.utils import get_average_rating, get_lesson_count
@frappe.whitelist()
def autosave_section(section, code):
"""Saves the code edited in one of the sections."""
doc = frappe.get_doc(
doctype="Code Revision", section=section, code=code, author=frappe.session.user
)
doc = frappe.get_doc(doctype="Code Revision", section=section, code=code, author=frappe.session.user)
doc.insert()
return {"name": doc.name}
@@ -95,9 +97,7 @@ def approve_cohort_join_request(join_request):
sg = r and frappe.get_doc("Cohort Subgroup", r.subgroup)
if not sg or r.status not in ["Pending", "Accepted"]:
return {"ok": False, "error": "Invalid Join Request"}
if (
not sg.is_manager(frappe.session.user) and "System Manager" not in frappe.get_roles()
):
if not sg.is_manager(frappe.session.user) and "System Manager" not in frappe.get_roles():
return {"ok": False, "error": "Permission Deined"}
r.status = "Accepted"
@@ -111,9 +111,7 @@ def reject_cohort_join_request(join_request):
sg = r and frappe.get_doc("Cohort Subgroup", r.subgroup)
if not sg or r.status not in ["Pending", "Rejected"]:
return {"ok": False, "error": "Invalid Join Request"}
if (
not sg.is_manager(frappe.session.user) and "System Manager" not in frappe.get_roles()
):
if not sg.is_manager(frappe.session.user) and "System Manager" not in frappe.get_roles():
return {"ok": False, "error": "Permission Deined"}
r.status = "Rejected"
@@ -128,9 +126,7 @@ def undo_reject_cohort_join_request(join_request):
# keeping Pending as well to consider the case of duplicate requests
if not sg or r.status not in ["Pending", "Rejected"]:
return {"ok": False, "error": "Invalid Join Request"}
if (
not sg.is_manager(frappe.session.user) and "System Manager" not in frappe.get_roles()
):
if not sg.is_manager(frappe.session.user) and "System Manager" not in frappe.get_roles():
return {"ok": False, "error": "Permission Deined"}
r.status = "Pending"
@@ -138,28 +134,6 @@ def undo_reject_cohort_join_request(join_request):
return {"ok": True}
@frappe.whitelist()
def add_mentor_to_subgroup(subgroup, email):
try:
sg = frappe.get_doc("Cohort Subgroup", subgroup)
except frappe.DoesNotExistError:
return {"ok": False, "error": f"Invalid subgroup: {subgroup}"}
if (
not sg.get_cohort().is_admin(frappe.session.user)
and "System Manager" not in frappe.get_roles()
):
return {"ok": False, "error": "Permission Deined"}
try:
user = frappe.get_doc("User", email)
except frappe.DoesNotExistError:
return {"ok": False, "error": f"Invalid user: {email}"}
sg.add_mentor(email)
return {"ok": True}
@frappe.whitelist(allow_guest=True)
def get_user_info():
if frappe.session.user == "Guest":
@@ -175,8 +149,13 @@ def get_user_info():
user.is_instructor = "Course Creator" in user.roles
user.is_moderator = "Moderator" in user.roles
user.is_evaluator = "Batch Evaluator" in user.roles
user.is_student = "LMS Student" in user.roles
user.is_student = not user.is_instructor and not user.is_moderator and not user.is_evaluator
user.is_fc_site = is_fc_site()
user.is_system_manager = "System Manager" in user.roles
user.sitename = frappe.local.site
user.developer_mode = frappe.conf.developer_mode
if user.is_fc_site and user.is_system_manager:
user.site_info = current_site_info()
return user
@@ -208,21 +187,28 @@ def validate_billing_access(billing_type, name):
message = _("Module Name is incorrect or does not exist.")
if access and billing_type == "course":
membership = frappe.db.exists(
"LMS Enrollment", {"member": frappe.session.user, "course": name}
)
membership = frappe.db.exists("LMS Enrollment", {"member": frappe.session.user, "course": name})
if membership:
access = False
message = _("You are already enrolled for this course.")
elif access and billing_type == "batch":
membership = frappe.db.exists(
"LMS Batch Enrollment", {"member": frappe.session.user, "batch": name}
)
membership = frappe.db.exists("LMS Batch Enrollment", {"member": frappe.session.user, "batch": name})
if membership:
access = False
message = _("You are already enrolled for this batch.")
seat_count = frappe.get_cached_value("LMS Batch", name, "seat_count")
number_of_students = frappe.db.count("LMS Batch Enrollment", {"batch": name})
if seat_count <= number_of_students:
access = False
message = _("Batch is sold out.")
start_date = frappe.get_cached_value("LMS Batch", name, "start_date")
if start_date and date_diff(start_date, now()) < 0:
access = False
message = _("Batch has already started.")
elif access and billing_type == "certificate":
purchased_certificate = frappe.db.exists(
"LMS Enrollment",
@@ -264,9 +250,11 @@ def get_job_details(job):
[
"job_title",
"location",
"country",
"type",
"company_name",
"company_logo",
"company_website",
"name",
"creation",
"description",
@@ -288,14 +276,20 @@ def get_job_opportunities(filters=None, orFilters=None):
fields=[
"job_title",
"location",
"country",
"type",
"company_name",
"company_logo",
"name",
"creation",
"description",
],
order_by="creation desc",
)
for job in jobs:
job.description = frappe.utils.strip_html_tags(job.description)
job.applicants = frappe.db.count("LMS Job Application", {"job": job.name})
return jobs
@@ -310,15 +304,9 @@ def get_chart_details():
"upcoming": 0,
},
)
details.users = frappe.db.count(
"User", {"enabled": 1, "name": ["not in", ("Administrator", "Guest")]}
)
details.completions = frappe.db.count(
"LMS Enrollment", {"progress": ["like", "%100%"]}
)
details.lesson_completions = frappe.db.count(
"LMS Course Progress", {"status": "Complete"}
)
details.users = frappe.db.count("User", {"enabled": 1, "name": ["not in", ("Administrator", "Guest")]})
details.completions = frappe.db.count("LMS Enrollment", {"progress": ["like", "%100%"]})
details.certifications = frappe.db.count("LMS Certificate", {"published": 1})
return details
@@ -349,7 +337,7 @@ def get_branding():
@frappe.whitelist()
def get_unsplash_photos(keyword=None):
from lms.unsplash import get_list, get_by_keyword
from lms.unsplash import get_by_keyword, get_list
if keyword:
return get_by_keyword(keyword)
@@ -385,7 +373,7 @@ def get_evaluator_details(evaluator):
@frappe.whitelist(allow_guest=True)
def get_certified_participants(filters=None, start=0, page_length=30):
def get_certified_participants(filters=None, start=0, page_length=100):
or_filters = {}
if not filters:
filters = {}
@@ -398,29 +386,52 @@ def get_certified_participants(filters=None, start=0, page_length=30):
or_filters["course_title"] = ["like", f"%{category}%"]
or_filters["batch_title"] = ["like", f"%{category}%"]
participants = frappe.get_all(
participants = frappe.db.get_all(
"LMS Certificate",
filters=filters,
or_filters=or_filters,
fields=["member"],
fields=["member", "issue_date"],
group_by="member",
order_by="creation desc",
order_by="issue_date desc",
start=start,
page_length=page_length,
)
for participant in participants:
count = frappe.db.count("LMS Certificate", {"member": participant.member})
details = frappe.db.get_value(
"User",
participant.member,
["full_name", "user_image", "username", "country", "headline"],
as_dict=1,
)
details["certificate_count"] = count
participant.update(details)
return participants
@frappe.whitelist(allow_guest=True)
def get_count_of_certified_members(filters=None):
Certificate = DocType("LMS Certificate")
query = (
frappe.qb.from_(Certificate).select(Certificate.member).distinct().where(Certificate.published == 1)
)
if filters:
for field, value in filters.items():
if field == "category":
query = query.where(
Certificate.course_title.like(f"%{value}%") | Certificate.batch_title.like(f"%{value}%")
)
elif field == "member_name":
query = query.where(Certificate.member_name.like(value[1]))
result = query.run(as_dict=True)
return len(result) or 0
@frappe.whitelist(allow_guest=True)
def get_certification_categories():
categories = []
@@ -450,9 +461,7 @@ def get_assigned_badges(member):
)
for badge in assigned_badges:
badge.update(
frappe.db.get_value("LMS Badge", badge.badge, ["name", "title", "image"])
)
badge.update(frappe.db.get_value("LMS Badge", badge.badge, ["name", "title", "image"]))
return assigned_badges
@@ -495,10 +504,11 @@ def get_sidebar_settings():
items = [
"courses",
"batches",
"certified_participants",
"certified_members",
"jobs",
"statistics",
"notifications",
"programming_exercises",
]
for item in items:
sidebar_items[item] = lms_settings.get(item)
@@ -621,6 +631,25 @@ def update_index(lessons, chapter):
)
@frappe.whitelist()
def update_chapter_index(chapter, course, idx):
"""Update the index of a chapter within a course"""
chapters = frappe.get_all(
"Chapter Reference",
{"parent": course},
pluck="chapter",
order_by="idx",
)
if chapter in chapters:
chapters.remove(chapter)
chapters.insert(idx, chapter)
for i, chapter_name in enumerate(chapters):
frappe.db.set_value("Chapter Reference", {"chapter": chapter_name, "parent": course}, "idx", i + 1)
@frappe.whitelist(allow_guest=True)
def get_categories(doctype, filters):
categoryOptions = []
@@ -641,16 +670,6 @@ def get_categories(doctype, filters):
@frappe.whitelist()
def get_members(start=0, search=""):
"""Get members for the given search term and start index.
Args: start (int): Start index for the query.
<<<<<<< HEAD
search (str): Search term to filter the results.
=======
search (str): Search term to filter the results.
>>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577
Returns: List of members.
"""
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
or_filters = {}
@@ -668,7 +687,14 @@ def get_members(start=0, search=""):
)
for member in members:
roles = frappe.get_roles(member.name)
roles = frappe.get_all(
"Has Role",
{
"parent": member.name,
"parenttype": "User",
},
pluck="role",
)
if "Moderator" in roles:
member.role = "Moderator"
elif "Course Creator" in roles:
@@ -710,9 +736,7 @@ def save_evaluation_details(
"""
Save evaluation details for a member against a course.
"""
evaluation = frappe.db.exists(
"LMS Certificate Evaluation", {"member": member, "course": course}
)
evaluation = frappe.db.exists("LMS Certificate Evaluation", {"member": member, "course": course})
details = {
"date": date,
@@ -789,6 +813,14 @@ def delete_documents(doctype, documents):
frappe.delete_doc(doctype, doc)
@frappe.whitelist(allow_guest=True)
def get_count(doctype, filters):
return frappe.db.count(
doctype,
filters=filters,
)
@frappe.whitelist()
def get_payment_gateway_details(payment_gateway):
fields = []
@@ -841,9 +873,7 @@ def update_course_statistics():
for course in courses:
lessons = get_lesson_count(course.name)
enrollments = frappe.db.count(
"LMS Enrollment", {"course": course.name, "member_type": "Student"}
)
enrollments = frappe.db.count("LMS Enrollment", {"course": course.name, "member_type": "Student"})
avg_rating = get_average_rating(course.name) or 0
avg_rating = flt(avg_rating, frappe.get_system_settings("float_precision") or 3)
@@ -876,28 +906,21 @@ def get_announcements(batch):
)
for communication in communications:
communication.image = frappe.get_cached_value(
"User", communication.sender, "user_image"
)
communication.image = frappe.get_cached_value("User", communication.sender, "user_image")
return communications
@frappe.whitelist()
def delete_course(course):
chapters = frappe.get_all("Course Chapter", {"course": course}, pluck="name")
chapter_references = frappe.get_all(
"Chapter Reference", {"parent": course}, pluck="name"
)
chapter_references = frappe.get_all("Chapter Reference", {"parent": course}, pluck="name")
for chapter in chapters:
lessons = frappe.get_all("Course Lesson", {"chapter": chapter}, pluck="name")
lesson_references = frappe.get_all(
"Lesson Reference", {"parent": chapter}, pluck="name"
)
lesson_references = frappe.get_all("Lesson Reference", {"parent": chapter}, pluck="name")
for lesson in lesson_references:
frappe.delete_doc("Lesson Reference", lesson)
@@ -929,7 +952,30 @@ def delete_course(course):
frappe.delete_doc("LMS Course", course)
def give_dicussions_permission():
@frappe.whitelist()
def delete_batch(batch):
frappe.db.delete("LMS Batch Enrollment", {"batch": batch})
frappe.db.delete("Batch Course", {"parent": batch, "parenttype": "LMS Batch"})
frappe.db.delete("LMS Assessment", {"parent": batch, "parenttype": "LMS Batch"})
frappe.db.delete("LMS Batch Timetable", {"parent": batch, "parenttype": "LMS Batch"})
frappe.db.delete("LMS Batch Feedback", {"batch": batch})
delete_batch_discussions(batch)
frappe.db.delete("LMS Batch", batch)
def delete_batch_discussions(batch):
topics = frappe.get_all(
"Discussion Topic",
{"reference_doctype": "LMS Batch", "reference_docname": batch},
pluck="name",
)
for topic in topics:
frappe.db.delete("Discussion Reply", {"topic": topic})
frappe.db.delete("Discussion Topic", topic)
def give_discussions_permission():
doctypes = ["Discussion Topic", "Discussion Reply"]
roles = ["LMS Student", "Course Creator", "Moderator", "Batch Evaluator"]
for doctype in doctypes:
@@ -950,9 +996,7 @@ def give_dicussions_permission():
@frappe.whitelist()
def upsert_chapter(title, course, is_scorm_package, scorm_package, name=None):
values = frappe._dict(
{"title": title, "course": course, "is_scorm_package": is_scorm_package}
)
values = frappe._dict({"title": title, "course": course, "is_scorm_package": is_scorm_package})
if is_scorm_package:
scorm_package = frappe._dict(scorm_package)
@@ -976,7 +1020,7 @@ def upsert_chapter(title, course, is_scorm_package, scorm_package, name=None):
chapter.save()
if is_scorm_package and not len(chapter.lessons):
add_lesson(title, chapter.name, course)
add_lesson(title, chapter.name, course, 1)
return chapter
@@ -1010,14 +1054,12 @@ def check_for_malicious_code(zip_path):
content = file.read().decode("utf-8", errors="ignore")
for pattern in suspicious_patterns:
if re.search(pattern, content):
frappe.throw(
_("Suspicious pattern found in {0}: {1}").format(file_name, pattern)
)
frappe.throw(_("Suspicious pattern found in {0}: {1}").format(file_name, pattern))
def get_manifest_file(extract_path):
manifest_file = None
for root, dirs, files in os.walk(extract_path):
for root, _dirs, files in os.walk(extract_path):
for file in files:
if file == "imsmanifest.xml":
manifest_file = os.path.join(root, file)
@@ -1050,7 +1092,7 @@ def get_launch_file(extract_path):
return launch_file
def add_lesson(title, chapter, course):
def add_lesson(title, chapter, course, idx):
lesson = frappe.new_doc("Course Lesson")
lesson.update(
{
@@ -1065,6 +1107,7 @@ def add_lesson(title, chapter, course):
lesson_reference.update(
{
"lesson": lesson.name,
"idx": idx,
"parent": chapter,
"parenttype": "Course Chapter",
"parentfield": "lessons",
@@ -1096,9 +1139,7 @@ def delete_scorm_package(scorm_package_path):
@frappe.whitelist()
def mark_lesson_progress(course, chapter_number, lesson_number):
chapter_name = frappe.get_value(
"Chapter Reference", {"parent": course, "idx": chapter_number}, "chapter"
)
chapter_name = frappe.get_value("Chapter Reference", {"parent": course, "idx": chapter_number}, "chapter")
lesson_name = frappe.get_value(
"Lesson Reference", {"parent": chapter_name, "idx": lesson_number}, "lesson"
)
@@ -1113,9 +1154,7 @@ def get_heatmap_data(member=None, base_days=200):
base_date, start_date, number_of_days, days = calculate_date_ranges(base_days)
date_count = initialize_date_count(days)
lesson_completions, quiz_submissions, assignment_submissions = fetch_activity_data(
member, start_date
)
lesson_completions, quiz_submissions, assignment_submissions = fetch_activity_data(member, start_date)
count_dates(lesson_completions, date_count)
count_dates(quiz_submissions, date_count)
count_dates(assignment_submissions, date_count)
@@ -1206,13 +1245,11 @@ def prepare_heatmap_data(start_date, number_of_days, date_count):
labels[column_index] = current_month
last_seen_month = current_month
for (index, label) in enumerate(labels):
for index, label in enumerate(labels):
if not label:
labels[index] = ""
formatted_heatmap_data = [
{"name": day, "data": heatmap_data[day]} for day in days_of_week
]
formatted_heatmap_data = [{"name": day, "data": heatmap_data[day]} for day in days_of_week]
total_activities = sum(date_count.values())
return formatted_heatmap_data, labels, total_activities, week_count
@@ -1242,8 +1279,8 @@ def get_notifications(filters):
@frappe.whitelist(allow_guest=True)
def is_guest_allowed():
return frappe.get_cached_value("LMS Settings", None, "allow_guest_access")
def get_lms_setting(field):
return frappe.get_cached_value("LMS Settings", None, field)
@frappe.whitelist()
@@ -1266,10 +1303,7 @@ def cancel_evaluation(evaluation):
info = frappe.db.get_value("Event", event.parent, ["starts_on", "subject"], as_dict=1)
date = str(info.starts_on).split(" ")[0]
if (
date == str(evaluation.date.format("YYYY-MM-DD"))
and evaluation.member_name in info.subject
):
if date == str(evaluation.date.format("YYYY-MM-DD")) and evaluation.member_name in info.subject:
communication = frappe.db.get_value(
"Communication",
{"reference_doctype": "Event", "reference_name": event.parent},
@@ -1291,10 +1325,303 @@ def get_certification_details(course):
membership = frappe.db.get_value(
"LMS Enrollment",
filters,
["name", "certificate", "purchased_certificate"],
["name", "purchased_certificate"],
as_dict=1,
)
paid_certificate = frappe.db.get_value("LMS Course", course, "paid_certificate")
certificate = frappe.db.get_value(
"LMS Certificate",
{"member": frappe.session.user, "course": course},
["name", "template"],
as_dict=1,
)
return {"membership": membership, "paid_certificate": paid_certificate}
return {
"membership": membership,
"paid_certificate": paid_certificate,
"certificate": certificate,
}
@frappe.whitelist()
def save_role(user, role, value):
frappe.only_for("Moderator")
if cint(value):
doc = frappe.get_doc(
{
"doctype": "Has Role",
"parent": user,
"role": role,
"parenttype": "User",
"parentfield": "roles",
}
)
doc.save(ignore_permissions=True)
else:
frappe.db.delete("Has Role", {"parent": user, "role": role})
return True
@frappe.whitelist()
def add_an_evaluator(email):
frappe.only_for("Moderator")
if not frappe.db.exists("User", email):
user = frappe.new_doc("User")
user.update(
{
"email": email,
"first_name": email.split("@")[0].capitalize(),
"enabled": 1,
}
)
user.insert()
user.add_roles("Batch Evaluator")
evaluator = frappe.new_doc("Course Evaluator")
evaluator.evaluator = email
evaluator.insert()
return evaluator
@frappe.whitelist()
def delete_evaluator(evaluator):
frappe.only_for("Moderator")
if not frappe.db.exists("Course Evaluator", evaluator):
frappe.throw(_("Evaluator does not exist."))
frappe.db.delete("Has Role", {"parent": evaluator, "role": "Batch Evaluator"})
frappe.db.delete("Course Evaluator", evaluator)
@frappe.whitelist()
def capture_user_persona(responses):
frappe.only_for("System Manager")
data = frappe.parse_json(responses)
data = json.dumps(data)
response = frappe.integrations.utils.make_post_request(
"https://school.frappe.io/api/method/capture-persona",
data={"response": data},
)
if response.get("message").get("name"):
frappe.db.set_single_value("LMS Settings", "persona_captured", True)
return response
@frappe.whitelist()
def get_meta_info(type, route):
if frappe.db.exists("Website Meta Tag", {"parent": f"{type}/{route}"}):
meta_tags = frappe.get_all(
"Website Meta Tag",
{
"parent": f"{type}/{route}",
},
["name", "key", "value"],
)
return meta_tags
return []
@frappe.whitelist()
def update_meta_info(type, route, meta_tags):
parent_name = f"{type}/{route}"
if not isinstance(meta_tags, list):
frappe.throw(_("Meta tags should be a list."))
for tag in meta_tags:
existing_tag = frappe.db.exists(
"Website Meta Tag",
{
"parent": parent_name,
"parenttype": "Website Route Meta",
"parentfield": "meta_tags",
"key": tag["key"],
},
)
if existing_tag:
if not tag.get("value"):
frappe.db.delete("Website Meta Tag", existing_tag)
continue
frappe.db.set_value("Website Meta Tag", existing_tag, "value", tag["value"])
elif tag.get("value"):
tag_properties = {
"parent": parent_name,
"parenttype": "Website Route Meta",
"parentfield": "meta_tags",
"key": tag["key"],
"value": tag["value"],
}
parent_exists = frappe.db.exists("Website Route Meta", parent_name)
if not parent_exists:
route_meta = frappe.new_doc("Website Route Meta")
route_meta.update(
{
"__newname": parent_name,
}
)
route_meta.append("meta_tags", tag_properties)
route_meta.insert()
else:
new_tag = frappe.new_doc("Website Meta Tag")
new_tag.update(tag_properties)
print(new_tag)
new_tag.insert()
print(new_tag.as_dict())
@frappe.whitelist()
def create_programming_exercise_submission(exercise, submission, code, test_cases):
if submission == "new":
return make_new_exercise_submission(exercise, code, test_cases)
else:
update_exercise_submission(submission, code, test_cases)
def make_new_exercise_submission(exercise, code, test_cases):
submission = frappe.new_doc("LMS Programming Exercise Submission")
submission.exercise = exercise
submission.member = frappe.session.user
submission.code = code
for test_case in test_cases:
submission.append(
"test_cases",
{
"input": test_case.get("input"),
"output": test_case.get("output"),
"expected_output": test_case.get("expected_output"),
"status": test_case.get("status", test_case.get("status", "Failed")),
},
)
submission.status = get_exercise_status(test_cases)
submission.insert()
return submission.name
def update_exercise_submission(submission, code, test_cases):
update_test_cases(test_cases, submission)
status = get_exercise_status(test_cases)
frappe.db.set_value("LMS Programming Exercise Submission", submission, {"status": status, "code": code})
def get_exercise_status(test_cases):
if not test_cases:
return "Failed"
if all(row.get("status", "Failed") == "Passed" for row in test_cases):
return "Passed"
else:
return "Failed"
def update_test_cases(test_cases, submission):
frappe.db.delete("LMS Test Case Submission", {"parent": submission})
for row in test_cases:
test_case = frappe.new_doc("LMS Test Case Submission")
test_case.update(
{
"parent": submission,
"parenttype": "LMS Programming Exercise Submission",
"parentfield": "test_cases",
"input": row.get("input"),
"output": row.get("output"),
"expected_output": row.get("expected_output"),
"status": row.get("status", "Failed"),
}
)
test_case.insert()
@frappe.whitelist()
def track_video_watch_duration(lesson, videos):
"""
Track the watch duration of videos in a lesson.
"""
if not isinstance(videos, list):
videos = json.loads(videos)
for video in videos:
filters = {
"lesson": lesson,
"source": video.get("source"),
"member": frappe.session.user,
}
existing_record = frappe.db.get_value(
"LMS Video Watch Duration", filters, ["name", "watch_time"], as_dict=True
)
if existing_record and flt(existing_record.watch_time) < flt(video.get("watch_time")):
frappe.db.set_value(
"LMS Video Watch Duration",
filters,
"watch_time",
video.get("watch_time"),
)
elif not existing_record:
track_new_watch_time(lesson, video)
def track_new_watch_time(lesson, video):
doc = frappe.new_doc("LMS Video Watch Duration")
doc.lesson = lesson
doc.source = video.get("source")
doc.watch_time = video.get("watch_time")
doc.member = frappe.session.user
doc.save()
@frappe.whitelist()
def get_course_progress_distribution(course):
all_progress = frappe.get_all(
"LMS Enrollment",
{
"course": course,
},
pluck="progress",
)
average_progress = get_average_course_progress(all_progress)
progress_distribution = get_progress_distribution(all_progress)
return {
"average_progress": average_progress,
"progress_distribution": progress_distribution,
}
def get_average_course_progress(progress_list):
if not progress_list:
return 0
average_progress = sum(progress_list) / len(progress_list)
return flt(average_progress, frappe.get_system_settings("float_precision") or 3)
def get_progress_distribution(progressList):
distribution = [
{
"category": "0-20%",
"count": len([p for p in progressList if 0 <= p < 20]),
},
{
"category": "20-40%",
"count": len([p for p in progressList if 20 <= p < 40]),
},
{
"category": "40-60%",
"count": len([p for p in progressList if 40 <= p < 60]),
},
{
"category": "60-80%",
"count": len([p for p in progressList if 60 <= p < 80]),
},
{
"category": "80-100%",
"count": len([p for p in progressList if 80 <= p <= 100]),
},
]
return distribution

View File

@@ -0,0 +1,31 @@
{
"based_on": "issue_date",
"chart_name": "Certification",
"chart_type": "Count",
"creation": "2025-04-28 17:47:28.517149",
"docstatus": 0,
"doctype": "Dashboard Chart",
"document_type": "LMS Certificate",
"dynamic_filters_json": "[]",
"filters_json": "[[\"LMS Certificate\",\"published\",\"=\",1,false]]",
"group_by_type": "Count",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"modified": "2025-04-28 17:47:28.517149",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "Certification",
"number_of_groups": 0,
"owner": "sayali@frappe.io",
"parent_document_type": "",
"roles": [],
"source": "",
"time_interval": "Daily",
"timeseries": 1,
"timespan": "Last Month",
"type": "Line",
"use_report_chart": 0,
"value_based_on": "",
"y_axis": []
}

View File

@@ -9,14 +9,14 @@
"doctype": "Dashboard Chart",
"document_type": "User",
"dynamic_filters_json": "[]",
"filters_json": "[]",
"filters_json": "[[\"User\",\"enabled\",\"=\",1,false]]",
"group_by_type": "Count",
"idx": 1,
"idx": 5,
"is_public": 1,
"is_standard": 1,
"last_synced_on": "2022-10-20 10:46:56.849265",
"modified": "2022-10-20 11:31:17.184897",
"modified_by": "Administrator",
"last_synced_on": "2025-04-28 15:09:52.161688",
"modified": "2025-04-28 17:47:58.168293",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "New Signups",
"number_of_groups": 0,
@@ -30,4 +30,4 @@
"use_report_chart": 0,
"value_based_on": "",
"y_axis": []
}
}

View File

@@ -31,9 +31,7 @@ class CohortSubgroup(Document):
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"
)
return frappe.get_all("Cohort Join Request", filters=q, fields=["*"], order_by="creation desc")
def get_mentors(self):
emails = frappe.get_all(

View File

@@ -103,6 +103,7 @@
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [
{
@@ -111,7 +112,7 @@
"link_fieldname": "chapter"
}
],
"modified": "2025-02-03 15:23:17.125617",
"modified": "2025-05-29 12:38:26.266673",
"modified_by": "Administrator",
"module": "LMS",
"name": "Course Chapter",
@@ -151,8 +152,21 @@
"role": "Course Creator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"search_fields": "title",
"show_title_field_in_link": 1,
"sort_field": "modified",
@@ -160,4 +174,4 @@
"states": [],
"title_field": "title",
"track_changes": 1
}
}

View File

@@ -3,8 +3,9 @@
import frappe
from frappe.model.document import Document
from lms.lms.utils import get_course_progress
from lms.lms.api import update_course_statistics
from lms.lms.utils import get_course_progress
class CourseChapter(Document):
@@ -13,15 +14,11 @@ class CourseChapter(Document):
update_course_statistics()
def recalculate_course_progress(self):
previous_lessons = (
self.get_doc_before_save() and self.get_doc_before_save().as_dict().lessons
)
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"]
)
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)

View File

@@ -8,6 +8,11 @@
"engine": "InnoDB",
"field_order": [
"evaluator",
"full_name",
"column_break_casg",
"user_image",
"username",
"section_break_ljse",
"schedule",
"unavailability_section",
"unavailable_from",
@@ -18,8 +23,10 @@
{
"fieldname": "evaluator",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Evaluator",
"options": "User",
"reqd": 1,
"unique": 1
},
{
@@ -46,11 +53,37 @@
"fieldname": "unavailable_to",
"fieldtype": "Date",
"label": "To"
},
{
"fetch_from": "evaluator.full_name",
"fieldname": "full_name",
"fieldtype": "Data",
"label": "Full Name"
},
{
"fieldname": "column_break_casg",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_ljse",
"fieldtype": "Section Break"
},
{
"fetch_from": "evaluator.user_image",
"fieldname": "user_image",
"fieldtype": "Attach Image",
"label": "User Image"
},
{
"fetch_from": "evaluator.username",
"fieldname": "username",
"fieldtype": "Data",
"label": "Username"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-02-24 12:17:08.436659",
"modified": "2025-07-04 12:04:11.007945",
"modified_by": "Administrator",
"module": "LMS",
"name": "Course Evaluator",
@@ -94,7 +127,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
"states": [],
"title_field": "full_name"
}

View File

@@ -1,19 +1,27 @@
# Copyright (c) 2022, Frappe and contributors
# For license information, please see license.txt
from datetime import datetime
import frappe
from frappe import _
from frappe.model.document import Document
from lms.lms.utils import get_evaluator
from datetime import datetime
from frappe.utils import get_time, getdate
from lms.lms.utils import get_evaluator
class CourseEvaluator(Document):
def validate(self):
self.validate_evaluator_role()
self.validate_time_slots()
self.validate_unavailability()
def validate_evaluator_role(self):
roles = frappe.get_roles(self.evaluator)
if "Batch Evaluator" not in roles:
frappe.get_doc("User", self.evaluator).add_roles("Batch Evaluator")
def validate_unavailability(self):
if (
self.unavailable_from
@@ -36,17 +44,9 @@ class CourseEvaluator(Document):
overlap = False
for slot in same_day_slots:
if (
get_time(schedule.start_time)
<= get_time(slot.start_time)
< get_time(schedule.end_time)
):
if get_time(schedule.start_time) <= get_time(slot.start_time) < get_time(schedule.end_time):
overlap = True
if (
get_time(schedule.start_time)
< get_time(slot.end_time)
<= get_time(schedule.end_time)
):
if get_time(schedule.start_time) < get_time(slot.end_time) <= get_time(schedule.end_time):
overlap = True
if get_time(slot.start_time) < get_time(schedule.start_time) and get_time(
schedule.end_time
@@ -79,9 +79,7 @@ def get_schedule(course, date, batch=None):
)
for slot in booked_slots:
same_slot = list(
filter(lambda x: x.start_time == slot.start_time and x.day == slot.day, all_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])

View File

@@ -161,7 +161,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-11-14 13:46:56.838659",
"modified": "2025-04-10 15:19:22.400932",
"modified_by": "Administrator",
"module": "LMS",
"name": "Course Lesson",
@@ -189,14 +189,28 @@
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"role": "Course Creator",
"select": 1,
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"select": 1,
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -1,77 +1,49 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
import json
import frappe
from frappe import _
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 ...md import find_macros
import json
from pydantic import BaseModel
from lms.lms.utils import get_course_progress
from ...md import find_macros
class CourseLesson(Document):
def validate(self):
# self.check_and_create_folder()
def on_update(self):
self.validate_quiz_id()
def validate_quiz_id(self):
if self.quiz_id and not frappe.db.exists("LMS Quiz", self.quiz_id):
frappe.throw(_("Invalid Quiz ID"))
def on_update(self):
dynamic_documents = ["Exercise", "Quiz"]
for section in dynamic_documents:
self.update_lesson_name_in_document(section)
if self.content:
self.save_lesson_details_in_quiz(self.content)
def update_lesson_name_in_document(self, section):
doctype_map = {"Exercise": "LMS Exercise", "Quiz": "LMS Quiz"}
macros = find_macros(self.body)
documents = [value for name, value in macros if name == section]
index = 1
for name in documents:
e = frappe.get_doc(doctype_map[section], name)
e.lesson = self.name
e.index_ = index
e.course = self.course
e.save(ignore_permissions=True)
index += 1
self.update_orphan_documents(doctype_map[section], documents)
if self.instructor_content:
self.save_lesson_details_in_quiz(self.instructor_content)
def update_orphan_documents(self, doctype, documents):
"""Updates the documents that were previously part of this lesson,
but not any more.
"""
linked_documents = {
row["name"] for row in frappe.get_all(doctype, {"lesson": self.name})
}
active_documents = set(documents)
orphan_documents = linked_documents - active_documents
for name in orphan_documents:
ex = frappe.get_doc(doctype, name)
ex.lesson = None
ex.course = None
ex.index_ = 0
ex.save(ignore_permissions=True)
def check_and_create_folder(self):
args = {
"doctype": "File",
"is_folder": True,
"file_name": f"{self.name} {self.course}",
}
if not frappe.db.exists(args):
folder = frappe.get_doc(args)
folder.save(ignore_permissions=True)
def get_exercises(self):
if not self.body:
return []
macros = find_macros(self.body)
exercises = [value for name, value in macros if name == "Exercise"]
return [frappe.get_doc("LMS Exercise", name) for name in exercises]
def save_lesson_details_in_quiz(self, content):
content = json.loads(self.content)
for block in content.get("blocks"):
if block.get("type") == "quiz":
quiz = block.get("data").get("quiz")
if not frappe.db.exists("LMS Quiz", quiz):
frappe.throw(_("Invalid Quiz ID in content"))
frappe.db.set_value(
"LMS Quiz",
quiz,
{
"course": self.course,
"lesson": self.name,
},
)
class SCORMDetails(BaseModel):
@@ -80,13 +52,12 @@ class SCORMDetails(BaseModel):
@frappe.whitelist()
def save_progress(lesson: str, course: str, scorm_details: SCORMDetails | None = None):
def save_progress(lesson, course, scorm_details=None):
"""
Note: Pass the argument scorm_details only if it is SCORM related save_progress
Note: Pass the argument scorm_details only if it is SCORM related save_progress,
scorm_details should be of type SCORMDetails
"""
membership = frappe.db.exists(
"LMS Enrollment", {"course": course, "member": frappe.session.user}
)
membership = frappe.db.exists("LMS Enrollment", {"course": course, "member": frappe.session.user})
if not membership:
return 0
@@ -102,12 +73,7 @@ def save_progress(lesson: str, course: str, scorm_details: SCORMDetails | None =
quiz_completed = get_quiz_progress(lesson)
assignment_completed = get_assignment_progress(lesson)
if (
not progress_already_exists
and quiz_completed
and assignment_completed
and not scorm_details
):
if not progress_already_exists and quiz_completed and assignment_completed and not scorm_details:
frappe.get_doc(
{
"doctype": "LMS Course Progress",
@@ -143,12 +109,19 @@ def save_progress(lesson: str, course: str, scorm_details: SCORMDetails | None =
progress = get_course_progress(course)
capture_progress_for_analytics(progress, course)
# Had to get doc, as on_change doesn't trigger when you use set_value. The trigger is necesary for badge to get assigned.
# 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)
enrollment.progress = progress
enrollment.save()
enrollment.run_method("on_change")
frappe.publish_realtime(
event="update_lesson_progress",
room=get_website_room(),
message={"course": course, "lesson": lesson, "progress": progress},
after_commit=True,
)
return progress
@@ -158,9 +131,7 @@ def capture_progress_for_analytics(progress, course):
def get_quiz_progress(lesson):
lesson_details = frappe.db.get_value(
"Course Lesson", lesson, ["body", "content"], as_dict=1
)
lesson_details = frappe.db.get_value("Course Lesson", lesson, ["body", "content"], as_dict=1)
quizzes = []
if lesson_details.content:
@@ -169,6 +140,11 @@ def get_quiz_progress(lesson):
for block in content.get("blocks"):
if block.get("type") == "quiz":
quizzes.append(block.get("data").get("quiz"))
if block.get("type") == "upload":
quizzes_in_video = block.get("data").get("quizzes")
if quizzes_in_video and len(quizzes_in_video) > 0:
for row in quizzes_in_video:
quizzes.append(row.get("quiz"))
elif lesson_details.body:
macros = find_macros(lesson_details.body)
@@ -189,9 +165,7 @@ def get_quiz_progress(lesson):
def get_assignment_progress(lesson):
lesson_details = frappe.db.get_value(
"Course Lesson", lesson, ["body", "content"], as_dict=1
)
lesson_details = frappe.db.get_value("Course Lesson", lesson, ["body", "content"], as_dict=1)
assignments = []
if lesson_details.content:

View File

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

View File

@@ -1,88 +0,0 @@
{
"actions": [],
"creation": "2021-04-29 16:29:56.857914",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"invite_email",
"signup_email",
"column_break_4",
"status",
"full_name",
"username",
"invite_code"
],
"fields": [
{
"allow_in_quick_entry": 1,
"fieldname": "invite_email",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Invite Email",
"options": "Email",
"unique": 1
},
{
"fieldname": "full_name",
"fieldtype": "Data",
"label": "Full Name"
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "signup_email",
"fieldtype": "Data",
"label": "Signup Email",
"options": "Email"
},
{
"fieldname": "username",
"fieldtype": "Data",
"label": "Username"
},
{
"fieldname": "invite_code",
"fieldtype": "Data",
"label": "Invite Code"
},
{
"default": "Pending",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Status",
"options": "Pending\nApproved\nRejected\nRegistered"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-05-03 09:22:20.954921",
"modified_by": "Administrator",
"module": "LMS",
"name": "Invite 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
}
],
"quick_entry": 1,
"search_fields": "invite_email, signup_email",
"sort_field": "modified",
"sort_order": "DESC",
"title_field": "invite_email",
"track_changes": 1
}

View File

@@ -1,96 +0,0 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
import json
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils.password import get_decrypted_password
class InviteRequest(Document):
def on_update(self):
if (
self.has_value_changed("status")
and self.status == "Approved"
and not frappe.flags.in_test
):
self.send_email()
def create_user(self, password):
full_name_split = self.full_name.split(" ")
user = frappe.get_doc(
{
"doctype": "User",
"email": self.signup_email,
"first_name": full_name_split[0],
"last_name": full_name_split[1] if len(full_name_split) > 1 else "",
"username": self.username,
"send_welcome_email": 0,
"user_type": "Website User",
"new_password": password,
}
)
user.save(ignore_permissions=True)
return user
def send_email(self):
site_name = "Mon.School"
subject = _("Welcome to {0}!").format(site_name)
args = {
"full_name": self.full_name,
"signup_form_link": f"/new-sign-up?invite_code={self.name}",
"site_name": site_name,
"site_url": frappe.utils.get_url(),
}
frappe.sendmail(
recipients=self.invite_email,
subject=subject,
header=[subject, "green"],
template="lms_invite_request_approved",
args=args,
now=True,
)
@frappe.whitelist(allow_guest=True)
def create_invite_request(invite_email):
if not frappe.utils.validate_email_address(invite_email):
return "invalid email"
if frappe.db.exists("User", invite_email):
return "user"
if frappe.db.exists("Invite Request", {"invite_email": invite_email}):
return "invite"
frappe.get_doc(
{"doctype": "Invite Request", "invite_email": invite_email, "status": "Approved"}
).save(ignore_permissions=True)
return "OK"
@frappe.whitelist(allow_guest=True)
def update_invite(data):
data = frappe._dict(json.loads(data)) if type(data) == str else frappe._dict(data)
try:
doc = frappe.get_doc("Invite Request", data.invite_code)
except frappe.DoesNotExistError:
frappe.throw(_("Invalid Invite Code."))
doc.signup_email = data.signup_email
doc.username = data.username
doc.full_name = data.full_name
doc.invite_code = data.invite_code
doc.save(ignore_permissions=True)
user = doc.create_user(data.password)
if user:
doc.status = "Registered"
doc.save(ignore_permissions=True)
return "OK"

View File

@@ -1,14 +0,0 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
import unittest
import frappe
from lms.lms.doctype.invite_request.invite_request import (
create_invite_request,
update_invite,
)
class TestInviteRequest(unittest.TestCase):
pass

View File

@@ -3,7 +3,8 @@
import frappe
from frappe.model.document import Document
from lms.lms.utils import has_course_moderator_role, has_course_instructor_role
from lms.lms.utils import has_course_instructor_role, has_course_moderator_role
class LMSAssignment(Document):

View File

@@ -146,11 +146,12 @@
"fieldtype": "Section Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2025-02-17 18:40:53.374932",
"modified_by": "Administrator",
"modified": "2025-07-14 10:24:23.526176",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Assignment Submission",
"naming_rule": "Expression (old style)",
@@ -179,8 +180,45 @@
"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
},
{
"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": [
@@ -202,4 +240,4 @@
}
],
"title_field": "assignment_title"
}
}

View File

@@ -3,10 +3,9 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import validate_url, validate_email_address
from frappe.email.doctype.email_template.email_template import get_email_template
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
from frappe.model.document import Document
from frappe.utils import validate_url
class LMSAssignmentSubmission(Document):
@@ -15,14 +14,6 @@ class LMSAssignmentSubmission(Document):
self.validate_url()
self.validate_status()
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()
def validate_duplicates(self):
if frappe.db.exists(
"LMS Assignment Submission",
@@ -30,61 +21,25 @@ class LMSAssignmentSubmission(Document):
):
lesson_title = frappe.db.get_value("Course Lesson", self.lesson, "title")
frappe.throw(
_("Assignment for Lesson {0} by {1} already exists.").format(
lesson_title, self.member_name
)
_("Assignment for Lesson {0} by {1} already exists.").format(lesson_title, self.member_name)
)
def validate_url(self):
if self.type == "URL" and not validate_url(self.answer):
frappe.throw(_("Please enter a valid URL."))
def send_mail(self):
subject = _("New Assignment Submission")
template = "assignment_submission"
custom_template = frappe.db.get_single_value(
"LMS Settings", "assignment_submission_template"
)
args = {
"member_name": self.member_name,
"assignment_name": self.assignment,
"assignment_title": self.assignment_title,
"submission_name": self.name,
}
moderators = frappe.get_all("Has Role", {"role": "Moderator"}, pluck="parent")
for moderator in moderators:
if not validate_email_address(moderator):
moderators.remove(moderator)
if custom_template:
email_template = get_email_template(custom_template, args)
subject = email_template.get("subject")
content = email_template.get("message")
frappe.sendmail(
recipients=moderators,
subject=subject,
template=template if not custom_template else None,
content=content if custom_template else None,
args=args,
header=[subject, "green"],
)
def validate_status(self):
if not self.is_new():
doc_before_save = self.get_doc_before_save()
if (
doc_before_save.status != self.status or doc_before_save.comments != self.comments
):
if doc_before_save.status != self.status or doc_before_save.comments != self.comments:
self.trigger_update_notification()
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": _("There has been an update on your submission for assignment {0}").format(
self.assignment_title
),
"email_content": self.comments,
"document_type": self.doctype,
"document_name": self.name,

View File

@@ -9,11 +9,11 @@
"enabled",
"title",
"description",
"reference_doctype",
"event",
"image",
"column_break_wgum",
"grant_only_once",
"event",
"reference_doctype",
"user_field",
"field_to_check",
"condition"
@@ -91,6 +91,7 @@
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [
{
@@ -98,7 +99,7 @@
"link_fieldname": "badge"
}
],
"modified": "2024-05-27 17:25:55.399830",
"modified": "2025-07-04 13:02:19.048994",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Badge",
@@ -127,9 +128,10 @@
"share": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}
}

View File

@@ -1,10 +1,11 @@
# Copyright (c) 2024, Frappe and contributors
# For license information, please see license.txt
import frappe
import json
from frappe.model.document import Document
import frappe
from frappe import _
from frappe.model.document import Document
class LMSBadge(Document):
@@ -27,17 +28,9 @@ class LMSBadge(Document):
def rule_condition_satisfied(self, doc):
doc_before_save = doc.get_doc_before_save()
if self.event == "Manual Assignment":
if self.event == "New" and doc_before_save is not None:
return False
if self.event == "New" and doc_before_save != None:
return False
if self.event == "Value Change":
field_to_check = self.field_to_check
if not field_to_check:
return False
if self.condition:
return eval_condition(doc, self.condition)

View File

@@ -7,8 +7,10 @@
"field_order": [
"member",
"member_name",
"issued_on",
"member_username",
"member_image",
"column_break_ugix",
"issued_on",
"badge",
"badge_image",
"badge_description"
@@ -65,12 +67,25 @@
"fieldtype": "Data",
"label": "Member Name",
"read_only": 1
},
{
"fetch_from": "member.username",
"fieldname": "member_username",
"fieldtype": "Data",
"label": "Member Username"
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach Image",
"label": "Member Image"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-06 12:32:28.450028",
"modified_by": "Administrator",
"modified": "2025-07-07 20:37:22.449149",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Badge Assignment",
"owner": "Administrator",
@@ -122,9 +137,10 @@
"share": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "member"
}
}

View File

@@ -26,6 +26,7 @@
"description",
"column_break_hlqw",
"instructors",
"zoom_account",
"section_break_rgfj",
"medium",
"category",
@@ -354,8 +355,15 @@
{
"fieldname": "section_break_cssv",
"fieldtype": "Section Break"
},
{
"fieldname": "zoom_account",
"fieldtype": "Link",
"label": "Zoom Account",
"options": "LMS Zoom Settings"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [
{
@@ -371,8 +379,8 @@
"link_fieldname": "batch_name"
}
],
"modified": "2025-02-18 15:43:18.512504",
"modified_by": "Administrator",
"modified": "2025-05-26 15:30:55.083507",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Batch",
"owner": "Administrator",
@@ -412,12 +420,22 @@
"role": "Batch Evaluator",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}
}

View File

@@ -1,29 +1,31 @@
# Copyright (c) 2022, Frappe and contributors
# For license information, please see license.txt
import frappe
import requests
import base64
import json
from frappe import _
from datetime import timedelta
import frappe
import requests
from frappe import _
from frappe.model.document import Document
from frappe.utils import cint, format_datetime, get_time, add_days, nowdate
from frappe.utils import add_days, cint, format_datetime, get_time, nowdate
from lms.lms.utils import (
generate_slug,
get_assignment_details,
get_lesson_index,
get_lesson_url,
get_quiz_details,
get_assignment_details,
update_payment_record,
generate_slug,
)
class LMSBatch(Document):
def validate(self):
if self.seat_count:
self.validate_seats_left()
self.validate_seats_left()
self.validate_batch_end_date()
self.validate_batch_time()
self.validate_duplicate_courses()
self.validate_payments_app()
self.validate_amount_and_currency()
@@ -40,20 +42,28 @@ class LMSBatch(Document):
if self.end_date < self.start_date:
frappe.throw(_("Batch end date cannot be before the batch start date"))
def validate_batch_time(self):
if self.start_time and self.end_time:
if get_time(self.start_time) >= get_time(self.end_time):
frappe.throw(_("Batch start time cannot be greater than or equal to end time."))
def validate_duplicate_courses(self):
courses = [row.course for row in self.courses]
duplicates = {course for course in courses if courses.count(course) > 1}
if len(duplicates):
title = frappe.db.get_value("LMS Course", next(iter(duplicates)), "title")
frappe.throw(
_("Course {0} has already been added to this batch.").format(frappe.bold(title))
)
frappe.throw(_("Course {0} has already been added to this batch.").format(frappe.bold(title)))
def validate_payments_app(self):
if self.paid_batch:
installed_apps = frappe.get_installed_apps()
if "payments" not in installed_apps:
frappe.throw(_("Please install the Payments app to create a paid batches."))
documentation_link = "https://docs.frappe.io/learning/setting-up-payment-gateway"
frappe.throw(
_(
"Please install the Payments App to create a paid batch. Refer to the documentation for more details. {0}"
).format(documentation_link)
)
def validate_amount_and_currency(self):
if self.paid_batch and (not self.amount or not self.currency):
@@ -63,13 +73,9 @@ class LMSBatch(Document):
assessments = [row.assessment_name for row in self.assessment]
for assessment in self.assessment:
if assessments.count(assessment.assessment_name) > 1:
title = frappe.db.get_value(
assessment.assessment_type, assessment.assessment_name, "title"
)
title = frappe.db.get_value(assessment.assessment_type, assessment.assessment_name, "title")
frappe.throw(
_("Assessment {0} has already been added to this batch.").format(
frappe.bold(title)
)
_("Assessment {0} has already been added to this batch.").format(frappe.bold(title))
)
def validate_evaluation_end_date(self):
@@ -80,17 +86,18 @@ class LMSBatch(Document):
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}
):
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."))
students = frappe.db.count("LMS Batch Enrollment", {"batch": self.name})
if cint(self.seat_count) < students:
if cint(self.seat_count) and cint(self.seat_count) < students:
frappe.throw(_("There are no seats available in this batch."))
def validate_timetable(self):
@@ -109,9 +116,7 @@ class LMSBatch(Document):
schedule.start_time
) > get_time(self.end_time):
frappe.throw(
_("Row #{0} Start time cannot be outside the batch duration.").format(
schedule.idx
)
_("Row #{0} Start time cannot be outside the batch duration.").format(schedule.idx)
)
if get_time(schedule.end_time) < get_time(self.start_time) or get_time(
@@ -122,9 +127,7 @@ class LMSBatch(Document):
)
if schedule.date < self.start_date or schedule.date > self.end_date:
frappe.throw(
_("Row #{0} Date cannot be outside the batch duration.").format(schedule.idx)
)
frappe.throw(_("Row #{0} Date cannot be outside the batch duration.").format(schedule.idx))
def on_payment_authorized(self, payment_status):
if payment_status in ["Authorized", "Completed"]:
@@ -133,7 +136,15 @@ class LMSBatch(Document):
@frappe.whitelist()
def create_live_class(
batch_name, title, duration, date, time, timezone, auto_recording, description=None
batch_name,
zoom_account,
title,
duration,
date,
time,
timezone,
auto_recording,
description=None,
):
frappe.only_for("Moderator")
payload = {
@@ -142,13 +153,11 @@ def create_live_class(
"duration": duration,
"agenda": description,
"private_meeting": True,
"auto_recording": "none"
if auto_recording == "No Recording"
else auto_recording.lower(),
"auto_recording": "none" if auto_recording == "No Recording" else auto_recording.lower(),
"timezone": timezone,
}
headers = {
"Authorization": "Bearer " + authenticate(),
"Authorization": "Bearer " + authenticate(zoom_account),
"content-type": "application/json",
}
response = requests.post(
@@ -162,6 +171,8 @@ def create_live_class(
"doctype": "LMS Live Class",
"start_url": data.get("start_url"),
"join_url": data.get("join_url"),
"meeting_id": data.get("id"),
"uuid": data.get("uuid"),
"title": title,
"host": frappe.session.user,
"date": date,
@@ -170,31 +181,30 @@ def create_live_class(
"password": data.get("password"),
"description": description,
"auto_recording": auto_recording,
"zoom_account": zoom_account,
}
)
class_details = frappe.get_doc(payload)
class_details.save()
return class_details
else:
frappe.throw(
_("Error creating live class. Please try again. {0}").format(response.text)
)
frappe.throw(_("Error creating live class. Please try again. {0}").format(response.text))
def authenticate():
zoom = frappe.get_single("Zoom Settings")
if not zoom.enable:
frappe.throw(_("Please enable Zoom Settings to use this feature."))
def authenticate(zoom_account):
zoom = frappe.get_doc("LMS Zoom Settings", zoom_account)
if not zoom.enabled:
frappe.throw(_("Please enable the zoom account to use this feature."))
authenticate_url = f"https://zoom.us/oauth/token?grant_type=account_credentials&account_id={zoom.account_id}"
authenticate_url = (
f"https://zoom.us/oauth/token?grant_type=account_credentials&account_id={zoom.account_id}"
)
headers = {
"Authorization": "Basic "
+ base64.b64encode(
bytes(
zoom.client_id
+ ":"
+ zoom.get_password(fieldname="client_secret", raise_exception=False),
zoom.client_id + ":" + zoom.get_password(fieldname="client_secret", raise_exception=False),
encoding="utf8",
)
).decode()
@@ -203,86 +213,6 @@ def authenticate():
return response.json()["access_token"]
@frappe.whitelist()
def create_batch(
title,
start_date,
end_date,
description=None,
batch_details=None,
batch_details_raw=None,
meta_image=None,
seat_count=0,
start_time=None,
end_time=None,
medium="Online",
category=None,
paid_batch=0,
amount=0,
currency=None,
amount_usd=0,
name=None,
published=0,
evaluation_end_date=None,
):
frappe.only_for("Moderator")
if name:
doc = frappe.get_doc("LMS Batch", name)
else:
doc = frappe.get_doc({"doctype": "LMS Batch"})
doc.update(
{
"title": title,
"start_date": start_date,
"end_date": end_date,
"description": description,
"batch_details": batch_details,
"batch_details_raw": batch_details_raw,
"meta_image": meta_image,
"seat_count": seat_count,
"start_time": start_time,
"end_time": end_time,
"medium": medium,
"category": category,
"paid_batch": paid_batch,
"amount": amount,
"currency": currency,
"amount_usd": amount_usd,
"published": published,
"evaluation_end_date": evaluation_end_date,
}
)
doc.save()
return doc
@frappe.whitelist()
def add_course(course, parent, name=None, evaluator=None):
frappe.only_for("Moderator")
if frappe.db.exists("Batch Course", {"course": course, "parent": parent}):
frappe.throw(_("Course already added to the batch."))
if name:
doc = frappe.get_doc("Batch Course", name)
else:
doc = frappe.new_doc("Batch Course")
doc.update(
{
"course": course,
"evaluator": evaluator,
"parent": parent,
"parentfield": "courses",
"parenttype": "LMS Batch",
}
)
doc.save()
return doc.name
@frappe.whitelist()
def get_batch_timetable(batch):
timetable = frappe.get_all(
@@ -329,15 +259,11 @@ def get_live_classes(batch):
def get_timetable_details(timetable):
for entry in timetable:
entry.title = frappe.db.get_value(
entry.reference_doctype, entry.reference_docname, "title"
)
entry.title = frappe.db.get_value(entry.reference_doctype, entry.reference_docname, "title")
assessment = frappe._dict({"assessment_name": entry.reference_docname})
if entry.reference_doctype == "Course Lesson":
course = frappe.db.get_value(
entry.reference_doctype, entry.reference_docname, "course"
)
course = frappe.db.get_value(entry.reference_doctype, entry.reference_docname, "course")
entry.url = get_lesson_url(course, get_lesson_index(entry.reference_docname))
entry.completed = (
@@ -366,43 +292,6 @@ def get_timetable_details(timetable):
return timetable
@frappe.whitelist()
def is_milestone_complete(idx, batch):
previous_rows = frappe.get_all(
"LMS Batch Timetable",
filters={"parent": batch, "idx": ["<", cint(idx)]},
fields=["reference_doctype", "reference_docname", "idx"],
order_by="idx",
)
for row in previous_rows:
if row.reference_doctype == "Course Lesson":
if not frappe.db.exists(
"LMS Course Progress",
{"member": frappe.session.user, "lesson": row.reference_docname},
):
return False
if row.reference_doctype == "LMS Quiz":
passing_percentage = frappe.db.get_value(
row.reference_doctype, row.reference_docname, "passing_percentage"
)
if not frappe.db.exists(
"LMS Quiz Submission",
{"quiz": row.reference_docname, "member": frappe.session.user},
):
return False
if row.reference_doctype == "LMS Assignment":
if not frappe.db.exists(
"LMS Assignment Submission",
{"assignment": row.reference_docname, "member": frappe.session.user},
):
return False
return True
def send_batch_start_reminder():
batches = frappe.get_all(
"LMS Batch",
@@ -411,15 +300,13 @@ def send_batch_start_reminder():
)
for batch in batches:
students = frappe.get_all(
"LMS Batch Enrollment", {"batch": batch}, ["member", "member_name"]
)
students = frappe.get_all("LMS Batch Enrollment", {"batch": batch.name}, ["member", "member_name"])
for student in students:
send_mail(batch, student)
def send_mail(batch, student):
subject = _("Batch Start Reminder")
subject = _("Your batch {0} is starting tomorrow").format(batch.title)
template = "batch_start_reminder"
args = {

View File

@@ -1,11 +1,12 @@
# Copyright (c) 2025, Frappe and contributors
# For license information, please see license.txt
import frappe
import json
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.email.doctype.email_template.email_template import get_email_template
from frappe.model.document import Document
class LMSBatchEnrollment(Document):
@@ -25,9 +26,7 @@ class LMSBatchEnrollment(Document):
frappe.throw(_("Member already enrolled in this batch"))
def validate_course_enrollment(self):
courses = frappe.get_all(
"Batch Course", filters={"parent": self.batch}, fields=["course"]
)
courses = frappe.get_all("Batch Course", filters={"parent": self.batch}, fields=["course"])
for course in courses:
if not frappe.db.exists(
@@ -40,9 +39,7 @@ class LMSBatchEnrollment(Document):
enrollment.save()
def add_member_to_live_class(self):
live_classes = frappe.get_all(
"LMS Live Class", {"batch_name": self.batch}, ["name", "event"]
)
live_classes = frappe.get_all("LMS Live Class", {"batch_name": self.batch}, ["name", "event"])
for live_class in live_classes:
if live_class.event:
@@ -68,9 +65,7 @@ def send_confirmation_email(doc):
outgoing_email_account = frappe.get_cached_value(
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
)
if not doc.confirmation_email_sent and (
outgoing_email_account or frappe.conf.get("mail_login")
):
if not doc.confirmation_email_sent and (outgoing_email_account or frappe.conf.get("mail_login")):
send_mail(doc)
frappe.db.set_value(doc.doctype, doc.name, "confirmation_email_sent", 1)

View File

@@ -4,7 +4,6 @@
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# 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

View File

@@ -73,10 +73,11 @@
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-13 19:02:58.259908",
"modified_by": "Administrator",
"modified": "2025-05-21 15:58:51.667270",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Batch Feedback",
"owner": "Administrator",
@@ -106,7 +107,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
"states": [],
"title_field": "member"
}

View File

@@ -4,7 +4,6 @@
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# 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

View File

@@ -63,9 +63,7 @@ def save_message(message, batch):
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}
)
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:

View File

@@ -21,7 +21,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-09-23 19:33:49.593950",
"modified": "2025-03-19 12:12:23.723432",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Category",
@@ -51,6 +51,26 @@
"role": "Moderator",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"select": 1,
"share": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"select": 1,
"share": 1
}
],
"sort_field": "modified",

View File

@@ -3,11 +3,12 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import add_years, nowdate
from lms.lms.utils import is_certified
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
class LMSCertificate(Document):

View File

@@ -4,7 +4,7 @@
frappe.ui.form.on("LMS Certificate Evaluation", {
refresh: function (frm) {
if (!frm.is_new() && frm.doc.status == "Pass") {
frm.add_custom_button(__("Create LMS Certificate"), () => {
frm.add_custom_button(__("Create Certificate"), () => {
frappe.model.open_mapped_doc({
method: "lms.lms.doctype.lms_certificate_evaluation.lms_certificate_evaluation.create_lms_certificate",
frm: frm,

View File

@@ -5,6 +5,7 @@ import frappe
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

View File

@@ -3,18 +3,15 @@
frappe.ui.form.on("LMS Certificate Request", {
refresh: function (frm) {
if (!frm.is_new()) {
frm.add_custom_button(
__("Create LMS Certificate Evaluation"),
() => {
frappe.model.open_mapped_doc({
method: "lms.lms.doctype.lms_certificate_request.lms_certificate_request.create_lms_certificate_evaluation",
frm: frm,
});
}
);
if (!frm.is_new() && frm.doc.status == "Upcoming") {
frm.add_custom_button(__("Conduct Evaluation"), () => {
frappe.model.open_mapped_doc({
method: "lms.lms.doctype.lms_certificate_request.lms_certificate_request.create_lms_certificate_evaluation",
frm: frm,
});
});
}
if (!frm.doc.google_meet_link) {
if (!frm.doc.google_meet_link && frm.doc.status == "Upcoming") {
frm.add_custom_button(__("Generate Google Meet Link"), () => {
frappe.call({
method: "lms.lms.doctype.lms_certificate_request.lms_certificate_request.setup_calendar_event",

View File

@@ -1,22 +1,24 @@
# Copyright (c) 2022, Frappe and contributors
# For license information, please see license.txt
import json
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.utils import (
add_to_date,
format_date,
format_time,
getdate,
add_to_date,
get_datetime,
nowtime,
get_time,
get_fullname,
get_time,
getdate,
nowtime,
)
from lms.lms.utils import get_evaluator
import json
class LMSCertificateRequest(Document):
@@ -77,6 +79,7 @@ class LMSCertificateRequest(Document):
"member": self.member,
"course": self.course,
"name": ["!=", self.name],
"status": "Upcoming",
},
["date", "start_time", "course"],
)
@@ -85,10 +88,7 @@ class LMSCertificateRequest(Document):
if (
req.date == getdate(self.date)
or getdate() < getdate(req.date)
or (
getdate() == getdate(req.date)
and getdate(self.start_time) < getdate(req.start_time)
)
or (getdate() == getdate(req.date) and get_time(nowtime()) < get_time(req.start_time))
):
course_title = frappe.db.get_value("LMS Course", req.course, "title")
frappe.throw(
@@ -98,16 +98,12 @@ class LMSCertificateRequest(Document):
course_title,
)
)
if getdate() == getdate(self.date) and get_time(self.start_time) < get_time(
nowtime()
):
if getdate() == getdate(self.date) and get_time(self.start_time) < get_time(nowtime()):
frappe.throw(_("You cannot schedule evaluations for past slots."))
def validate_evaluation_end_date(self):
if self.batch_name:
evaluation_end_date = frappe.db.get_value(
"LMS Batch", self.batch_name, "evaluation_end_date"
)
evaluation_end_date = frappe.db.get_value("LMS Batch", self.batch_name, "evaluation_end_date")
if evaluation_end_date:
if getdate(self.date) > getdate(evaluation_end_date):
@@ -150,7 +146,11 @@ def schedule_evals():
timelapse = add_to_date(get_datetime(), hours=-5)
evals = frappe.get_all(
"LMS Certificate Request",
{"creation": [">=", timelapse], "google_meet_link": ["is", "not set"]},
{
"creation": [">=", timelapse],
"google_meet_link": ["is", "not set"],
"status": "Upcoming",
},
["name", "member", "member_name", "evaluator", "date", "start_time", "end_time"],
)
for eval in evals:
@@ -162,9 +162,7 @@ def setup_calendar_event(eval):
if isinstance(eval, str):
eval = frappe._dict(json.loads(eval))
calendar = frappe.db.get_value(
"Google Calendar", {"user": eval.evaluator, "enable": 1}, "name"
)
calendar = frappe.db.get_value("Google Calendar", {"user": eval.evaluator, "enable": 1}, "name")
if calendar:
event = create_event(eval)
@@ -214,15 +212,11 @@ def update_meeting_details(eval, event, calendar):
event.save()
event.reload()
frappe.db.set_value(
"LMS Certificate Request", eval.name, "google_meet_link", event.google_meet_link
)
frappe.db.set_value("LMS Certificate Request", eval.name, "google_meet_link", event.google_meet_link)
@frappe.whitelist()
def create_certificate_request(
course, date, day, start_time, end_time, batch_name=None
):
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}
)
@@ -254,3 +248,20 @@ def create_lms_certificate_evaluation(source_name, target_doc=None):
target_doc,
)
return doc
def mark_eval_as_completed():
requests = frappe.get_all(
"LMS Certificate Request",
{
"status": "Upcoming",
"date": ["<=", getdate()],
},
["name", "end_time", "date"],
)
for req in requests:
if req.date < getdate():
frappe.db.set_value("LMS Certificate Request", req.name, "status", "Completed")
elif req.date == getdate() and get_time(req.end_time) < get_time(nowtime()):
frappe.db.set_value("LMS Certificate Request", req.name, "status", "Completed")

View File

@@ -16,13 +16,14 @@
"field_order": [
"title",
"video_link",
"tags",
"column_break_3",
"instructors",
"tags",
"column_break_htgn",
"image",
"category",
"status",
"column_break_htgn",
"image",
"card_gradient",
"section_break_7",
"published",
"published_on",
@@ -98,8 +99,7 @@
{
"fieldname": "image",
"fieldtype": "Attach Image",
"label": "Preview Image",
"reqd": 1
"label": "Preview Image"
},
{
"fieldname": "tags",
@@ -242,14 +242,14 @@
{
"default": "0",
"fieldname": "enrollments",
"fieldtype": "Data",
"fieldtype": "Int",
"label": "Enrollments",
"read_only": 1
},
{
"default": "0",
"fieldname": "lessons",
"fieldtype": "Data",
"fieldtype": "Int",
"label": "Lessons",
"read_only": 1
},
@@ -272,34 +272,32 @@
"fieldtype": "Link",
"label": "Evaluator",
"options": "Course Evaluator"
},
{
"fieldname": "card_gradient",
"fieldtype": "Select",
"label": "Color",
"options": "Red\nBlue\nGreen\nAmber\nCyan\nOrange\nPink\nPurple\nTeal\nViolet\nYellow\nGray"
}
],
"is_published_field": "published",
"links": [
{
"group": "Chapters",
"link_doctype": "LMS Enrollment",
"link_fieldname": "course"
},
{
"link_doctype": "Course Chapter",
"link_fieldname": "course"
},
{
"group": "Batches",
"link_doctype": "LMS Batch Old",
"link_fieldname": "course"
},
{
"group": "Mentors",
"link_doctype": "LMS Course Mentor Mapping",
"link_fieldname": "course"
},
{
"group": "Interests",
"link_doctype": "LMS Course Interest",
"link_doctype": "Course Lesson",
"link_fieldname": "course"
}
],
"make_attachments_public": 1,
"modified": "2025-02-24 11:50:58.325804",
"modified_by": "Administrator",
"modified": "2025-07-25 17:50:44.983391",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Course",
"owner": "Administrator",
@@ -327,12 +325,25 @@
"role": "Course Creator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}
}

View File

@@ -3,12 +3,15 @@
import json
import random
import frappe
from frappe.model.document import Document
from frappe.utils import today, cint
from lms.lms.utils import get_chapters
from ...utils import generate_slug, validate_image, update_payment_record
from frappe import _
from frappe.model.document import Document
from frappe.utils import cint, today
from lms.lms.utils import get_chapters
from ...utils import generate_slug, update_payment_record, validate_image
class LMSCourse(Document):
@@ -21,6 +24,7 @@ class LMSCourse(Document):
self.validate_certification()
self.validate_amount_and_currency()
self.image = validate_image(self.image)
self.validate_card_gradient()
def validate_published(self):
if self.published and not self.published_on:
@@ -50,13 +54,16 @@ class LMSCourse(Document):
if self.paid_course:
installed_apps = frappe.get_installed_apps()
if "payments" not in installed_apps:
frappe.throw(_("Please install the Payments app to create a paid courses."))
documentation_link = "https://docs.frappe.io/learning/setting-up-payment-gateway"
frappe.throw(
_(
"Please install the Payments App to create a paid course. Refer to the documentation for more details. {0}"
).format(documentation_link)
)
def validate_certification(self):
if self.enable_certification and self.paid_certificate:
frappe.throw(
_("A course cannot have both paid certificate and certificate of completion.")
)
frappe.throw(_("A course cannot have both paid certificate and certificate of completion."))
if self.paid_certificate and not self.evaluator:
frappe.throw(_("Evaluator is required for paid certificates."))
@@ -68,6 +75,24 @@ class LMSCourse(Document):
if self.paid_certificate and (cint(self.course_price) <= 0 or not self.currency):
frappe.throw(_("Amount and currency are required for paid certificates."))
def validate_card_gradient(self):
if not self.image and not self.card_gradient:
colors = [
"Red",
"Blue",
"Green",
"Yellow",
"Orange",
"Pink",
"Amber",
"Violet",
"Cyan",
"Teal",
"Gray",
"Purple",
]
self.card_gradient = random.choice(colors)
def on_update(self):
if not self.upcoming and self.has_value_changed("upcoming"):
self.send_email_to_interested_users()
@@ -77,9 +102,7 @@ class LMSCourse(Document):
update_payment_record("LMS Course", self.name)
def send_email_to_interested_users(self):
interested_users = frappe.get_all(
"LMS Course Interest", {"course": self.name}, ["name", "user"]
)
interested_users = frappe.get_all("LMS Course Interest", {"course": self.name}, ["name", "user"])
subject = self.title + " is available!"
args = {
"title": self.title,
@@ -98,9 +121,7 @@ class LMSCourse(Document):
args=args,
now=True,
)
frappe.enqueue(
method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args
)
frappe.enqueue(method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args)
frappe.db.set_value("LMS Course Interest", user.name, "email_sent", True)
def autoname(self):
@@ -115,9 +136,7 @@ class LMSCourse(Document):
if not email or email == "Guest":
return False
mapping = frappe.get_all(
"LMS Course Mentor Mapping", {"course": self.name, "mentor": email}
)
mapping = frappe.get_all("LMS Course Mentor Mapping", {"course": self.name, "mentor": email})
return mapping != []
def add_mentor(self, email):
@@ -131,9 +150,7 @@ class LMSCourse(Document):
if self.has_mentor(email):
return
doc = frappe.get_doc(
{"doctype": "LMS Course Mentor Mapping", "course": self.name, "mentor": email}
)
doc = frappe.get_doc({"doctype": "LMS Course Mentor Mapping", "course": self.name, "mentor": email})
doc.insert()
def get_student_batch(self, email):
@@ -189,9 +206,7 @@ class LMSCourse(Document):
"LMS Enrollment", {"member": member, "course": self.name}, ["batch_old"]
)
for membership in all_memberships:
membership.batch_title = frappe.db.get_value(
"LMS Batch Old", membership.batch_old, "title"
)
membership.batch_title = frappe.db.get_value("LMS Batch Old", membership.batch_old, "title")
return all_memberships

View File

@@ -18,13 +18,11 @@ class TestLMSCourse(unittest.TestCase):
course = new_course("Test Course")
assert course.get_mentors() == []
user = new_user("Tester", "tester@example.com")
new_user("Tester", "tester@example.com")
course.add_mentor("tester@example.com")
mentors = course.get_mentors()
mentors_data = [
dict(email=mentor.email, batch_count=mentor.batch_count) for mentor in mentors
]
mentors_data = [dict(email=mentor.email, batch_count=mentor.batch_count) for mentor in mentors]
assert mentors_data == [{"email": "tester@example.com", "batch_count": 0}]
def tearDown(self):
@@ -95,6 +93,6 @@ def new_course(title, additional_filters=None):
def create_evaluator():
if not frappe.db.exists("Course Evaluator", "evaluator@example.com"):
new_user("Evaluator", "evaluator@example.com")
frappe.get_doc(
{"doctype": "Course Evaluator", "evaluator": "evaluator@example.com"}
).save(ignore_permissions=True)
frappe.get_doc({"doctype": "Course Evaluator", "evaluator": "evaluator@example.com"}).save(
ignore_permissions=True
)

View File

@@ -12,6 +12,4 @@ class LMSCourseMentorMapping(Document):
"LMS Course Mentor Mapping", filters={"course": self.course, "mentor": self.mentor}
)
if len(duplicate_mapping):
frappe.throw(
_("{0} is already a mentor for course {1}").format(self.mentor_name, self.course)
)
frappe.throw(_("{0} is already a mentor for course {1}").format(self.mentor_name, self.course))

View File

@@ -3,6 +3,8 @@
import frappe
from frappe.model.document import Document
from lms.lms.doctype.lms_enrollment.lms_enrollment import update_program_progress
from lms.lms.utils import get_course_progress
@@ -18,3 +20,4 @@ class LMSCourseProgress(Document):
"name",
)
frappe.db.set_value("LMS Enrollment", membership, "progress", progress)
update_program_progress(self.member)

View File

@@ -11,9 +11,7 @@ class LMSCourseReview(Document):
self.validate_if_already_reviewed()
def validate_if_already_reviewed(self):
if frappe.db.exists(
"LMS Course Review", {"course": self.course, "owner": self.owner}
):
if frappe.db.exists("LMS Course Review", {"course": self.course, "owner": self.owner}):
frappe.throw(frappe._("You have already reviewed this course"))

View File

@@ -14,6 +14,7 @@
"member",
"member_name",
"member_username",
"member_image",
"certification_section",
"purchased_certificate",
"certificate",
@@ -91,7 +92,7 @@
"fetch_from": "member.username",
"fieldname": "member_username",
"fieldtype": "Data",
"label": "Memeber Username",
"label": "Member Username",
"read_only": 1
},
{
@@ -143,11 +144,18 @@
"fieldtype": "Link",
"label": "Certificate",
"options": "LMS Certificate"
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach Image",
"label": "Member Image"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-02-21 17:11:37.986157",
"modified": "2025-07-02 21:27:30.733482",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Enrollment",
@@ -192,10 +200,11 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "member_name",
"track_changes": 1
}
}

View File

@@ -13,7 +13,7 @@ class LMSEnrollment(Document):
self.validate_membership_in_different_batch_same_course()
def on_update(self):
self.update_program_progress()
update_program_progress(self.member)
def validate_membership_in_same_batch(self):
filters = {"member": self.member, "course": self.course, "name": ["!=", self.name]}
@@ -59,32 +59,29 @@ class LMSEnrollment(Document):
)
)
def update_program_progress(self):
programs = frappe.get_all(
"LMS Program Member", {"member": self.member}, ["parent", "name"]
)
for program in programs:
total_progress = 0
courses = frappe.get_all(
"LMS Program Course", {"parent": program.parent}, pluck="course"
)
for course in courses:
progress = frappe.db.get_value(
"LMS Enrollment", {"course": course, "member": self.member}, "progress"
)
progress = progress or 0
total_progress += progress
def update_program_progress(member):
programs = frappe.get_all("LMS Program Member", {"member": member}, ["parent", "name"])
average_progress = ceil(total_progress / len(courses))
frappe.db.set_value("LMS Program Member", program.name, "progress", average_progress)
for program in programs:
total_progress = 0
courses = frappe.get_all("LMS Program Course", {"parent": program.parent}, pluck="course")
for course in courses:
progress = frappe.db.get_value("LMS Enrollment", {"course": course, "member": member}, "progress")
progress = progress or 0
total_progress += progress
average_progress = ceil(total_progress / len(courses))
frappe.db.set_value("LMS Program Member", program.name, "progress", average_progress)
@frappe.whitelist()
def create_membership(
course, batch=None, member=None, member_type="Student", role="Member"
):
frappe.get_doc(
def create_membership(course, batch=None, member=None, member_type="Student", role="Member"):
if frappe.db.get_value("LMS Course", course, "disable_self_learning"):
return False
enrollment = frappe.new_doc("LMS Enrollment")
enrollment.update(
{
"doctype": "LMS Enrollment",
"batch_old": batch,
@@ -93,20 +90,17 @@ def create_membership(
"member_type": member_type,
"member": member or frappe.session.user,
}
).save(ignore_permissions=True)
return "OK"
)
enrollment.insert()
return enrollment
@frappe.whitelist()
def update_current_membership(batch, course, member):
all_memberships = frappe.get_all(
"LMS Enrollment", {"member": member, "course": course}
)
all_memberships = frappe.get_all("LMS Enrollment", {"member": member, "course": course})
for membership in all_memberships:
frappe.db.set_value("LMS Enrollment", membership.name, "is_current", 0)
current_membership = frappe.get_all(
"LMS Enrollment", {"batch_old": batch, "member": member}
)
current_membership = frappe.get_all("LMS Enrollment", {"batch_old": batch, "member": member})
if len(current_membership):
frappe.db.set_value("LMS Enrollment", current_membership[0].name, "is_current", 1)

View File

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

View File

@@ -0,0 +1,140 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "hash",
"creation": "2025-08-04 13:17:19.497483",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"lesson",
"course",
"column_break_qgrb",
"member",
"color",
"section_break_smzm",
"highlighted_text",
"column_break_zvrs",
"note"
],
"fields": [
{
"fieldname": "lesson",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Lesson",
"options": "Course Lesson",
"reqd": 1
},
{
"fetch_from": "lesson.course",
"fieldname": "course",
"fieldtype": "Link",
"label": "Course",
"options": "LMS Course"
},
{
"fieldname": "column_break_qgrb",
"fieldtype": "Column Break"
},
{
"fieldname": "member",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Member",
"options": "User",
"reqd": 1
},
{
"fieldname": "color",
"fieldtype": "Select",
"label": "Color",
"options": "Red\nBlue\nGreen\nYellow\nPurple",
"reqd": 1
},
{
"fieldname": "section_break_smzm",
"fieldtype": "Section Break"
},
{
"fieldname": "highlighted_text",
"fieldtype": "Small Text",
"label": "Highlighted Text"
},
{
"fieldname": "column_break_zvrs",
"fieldtype": "Column Break"
},
{
"fieldname": "note",
"fieldtype": "Text Editor",
"label": "Note"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-08-05 19:08:47.858172",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Lesson Note",
"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": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"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": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "member"
}

View File

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

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 IntegrationTestLMSLessonNote(IntegrationTestCase):
"""
Integration tests for LMSLessonNote.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -9,21 +9,27 @@
"field_order": [
"title",
"host",
"zoom_account",
"batch_name",
"event",
"column_break_astv",
"description",
"section_break_glxh",
"date",
"duration",
"column_break_spvt",
"time",
"duration",
"timezone",
"section_break_yrpq",
"section_break_glxh",
"description",
"column_break_spvt",
"event",
"auto_recording",
"section_break_fhet",
"meeting_id",
"uuid",
"column_break_aony",
"attendees",
"password",
"section_break_yrpq",
"start_url",
"column_break_yokr",
"auto_recording",
"join_url"
],
"fields": [
@@ -73,8 +79,7 @@
},
{
"fieldname": "section_break_glxh",
"fieldtype": "Section Break",
"label": "Date and Time"
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_spvt",
@@ -130,13 +135,50 @@
"label": "Event",
"options": "Event",
"read_only": 1
},
{
"fieldname": "zoom_account",
"fieldtype": "Link",
"label": "Zoom Account",
"options": "LMS Zoom Settings",
"reqd": 1
},
{
"fieldname": "meeting_id",
"fieldtype": "Data",
"label": "Meeting ID"
},
{
"fieldname": "attendees",
"fieldtype": "Int",
"label": "Attendees",
"read_only": 1
},
{
"fieldname": "section_break_fhet",
"fieldtype": "Section Break"
},
{
"fieldname": "uuid",
"fieldtype": "Data",
"label": "UUID"
},
{
"fieldname": "column_break_aony",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-11-11 18:59:26.396111",
"modified_by": "Administrator",
"links": [
{
"link_doctype": "LMS Live Class Participant",
"link_fieldname": "live_class"
}
],
"modified": "2025-05-27 14:44:35.679712",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Live Class",
"owner": "Administrator",
@@ -175,10 +217,11 @@
"share": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}
}

View File

@@ -1,18 +1,21 @@
# Copyright (c) 2023, Frappe and contributors
# For license information, please see license.txt
import json
from datetime import timedelta
import frappe
import requests
from frappe import _
from frappe.model.document import Document
from datetime import timedelta
from frappe.utils import cint, get_datetime, format_date, nowdate, format_time
from frappe.utils import cint, format_date, format_time, get_datetime, nowdate
from lms.lms.doctype.lms_batch.lms_batch import authenticate
class LMSLiveClass(Document):
def after_insert(self):
calendar = frappe.db.get_value(
"Google Calendar", {"user": frappe.session.user, "enable": 1}, "name"
)
calendar = frappe.db.get_value("Google Calendar", {"user": frappe.session.user, "enable": 1}, "name")
if calendar:
event = self.create_event()
@@ -34,9 +37,7 @@ class LMSLiveClass(Document):
return event
def add_event_participants(self, event, calendar):
participants = frappe.get_all(
"LMS Batch Enrollment", {"batch": self.batch_name}, pluck="member"
)
participants = frappe.get_all("LMS Batch Enrollment", {"batch": self.batch_name}, pluck="member")
participants.append(frappe.session.user)
for participant in participants:
@@ -84,7 +85,7 @@ def send_live_class_reminder():
def send_mail(live_class, student):
subject = f"Your class on {live_class.title} is tomorrow"
subject = _("Your class on {0} is today").format(live_class.title)
template = "live_class_reminder"
args = {
@@ -102,3 +103,56 @@ def send_mail(live_class, student):
args=args,
header=[_(f"Class Reminder: {live_class.title}"), "orange"],
)
def update_attendance():
past_live_classes = frappe.get_all(
"LMS Live Class",
{
"uuid": ["is", "set"],
"attendees": ["is", "not set"],
},
["name", "uuid", "zoom_account"],
)
for live_class in past_live_classes:
attendance_data = get_attendance(live_class)
create_attendance(live_class, attendance_data)
update_attendees_count(live_class, attendance_data)
def get_attendance(live_class):
headers = {
"Authorization": "Bearer " + authenticate(live_class.zoom_account),
"content-type": "application/json",
}
encoded_uuid = requests.utils.quote(live_class.uuid, safe="")
response = requests.get(
f"https://api.zoom.us/v2/past_meetings/{encoded_uuid}/participants", headers=headers
)
if response.status_code != 200:
frappe.throw(
_("Failed to fetch attendance data from Zoom for class {0}: {1}").format(
live_class, response.text
)
)
data = response.json()
return data.get("participants", [])
def create_attendance(live_class, data):
for participant in data:
doc = frappe.new_doc("LMS Live Class Participant")
doc.live_class = live_class.name
doc.member = participant.get("user_email")
doc.joined_at = participant.get("join_time")
doc.left_at = participant.get("leave_time")
doc.duration = participant.get("duration")
doc.insert()
def update_attendees_count(live_class, data):
frappe.db.set_value("LMS Live Class", live_class.name, "attendees", len(data))

View File

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

View File

@@ -0,0 +1,116 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-05-27 12:09:57.712221",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"live_class",
"joined_at",
"column_break_dwbm",
"duration",
"left_at",
"section_break_xczy",
"member",
"member_name",
"column_break_bpjn",
"member_image",
"member_username"
],
"fields": [
{
"fieldname": "live_class",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Live Class",
"options": "LMS Live Class",
"reqd": 1
},
{
"fieldname": "member",
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Member",
"options": "User",
"reqd": 1
},
{
"fetch_from": "member.full_name",
"fieldname": "member_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Member Name"
},
{
"fieldname": "column_break_dwbm",
"fieldtype": "Column Break"
},
{
"fieldname": "duration",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Duration",
"reqd": 1
},
{
"fieldname": "joined_at",
"fieldtype": "Datetime",
"label": "Joined At",
"reqd": 1
},
{
"fieldname": "left_at",
"fieldtype": "Datetime",
"label": "Left At",
"reqd": 1
},
{
"fieldname": "section_break_xczy",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_bpjn",
"fieldtype": "Column Break"
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach Image",
"label": "Member Image"
},
{
"fetch_from": "member.username",
"fieldname": "member_username",
"fieldtype": "Data",
"label": "Member Username"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-05-27 22:32:24.196643",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Live Class Participant",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "member_name"
}

View File

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

View File

@@ -0,0 +1,29 @@
# Copyright (c) 2025, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# 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 UnitTestLMSLiveClassParticipant(UnitTestCase):
"""
Unit tests for LMSLiveClassParticipant.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestLMSLiveClassParticipant(IntegrationTestCase):
"""
Integration tests for LMSLiveClassParticipant.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -9,7 +9,6 @@ from frappe.model.document import Document
class LMSMentorRequest(Document):
def on_update(self):
if self.has_value_changed("status"):
if self.status == "Approved":
self.create_course_mentor_mapping()
@@ -49,18 +48,14 @@ class LMSMentorRequest(Document):
"header": email_template.subject,
"message": message,
}
frappe.enqueue(
method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args
)
frappe.enqueue(method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args)
def send_status_change_email(self):
email_template = self.get_email_template("mentor_request_status_update")
if not email_template:
return
course_details = frappe.db.get_value(
"LMS Course", self.course, ["owner", "title"], as_dict=True
)
course_details = frappe.db.get_value("LMS Course", self.course, ["owner", "title"], as_dict=True)
message = frappe.render_template(
email_template.response,
{
@@ -78,9 +73,7 @@ class LMSMentorRequest(Document):
"header": email_template.subject,
"message": message,
}
frappe.enqueue(
method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args
)
frappe.enqueue(method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args)
elif self.status == "Withdrawn":
email_args = {
@@ -89,9 +82,7 @@ class LMSMentorRequest(Document):
"header": email_template.subject,
"message": message,
}
frappe.enqueue(
method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args
)
frappe.enqueue(method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args)
def get_email_template(self, template_name):
template = frappe.db.get_single_value("LMS Settings", template_name)

View File

@@ -44,13 +44,15 @@
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Amount",
"options": "currency"
"options": "currency",
"reqd": 1
},
{
"fieldname": "currency",
"fieldtype": "Link",
"label": "Currency",
"options": "Currency"
"options": "Currency",
"reqd": 1
},
{
"fieldname": "column_break_rqkd",
@@ -70,7 +72,8 @@
"fieldname": "address",
"fieldtype": "Link",
"label": "Address",
"options": "Address"
"options": "Address",
"reqd": 1
},
{
"default": "0",
@@ -124,13 +127,15 @@
"fieldname": "payment_for_document_type",
"fieldtype": "Select",
"label": "Payment for Document Type",
"options": "\nLMS Course\nLMS Batch"
"options": "\nLMS Course\nLMS Batch",
"reqd": 1
},
{
"fieldname": "payment_for_document",
"fieldtype": "Dynamic Link",
"label": "Payment for Document",
"options": "payment_for_document_type"
"options": "payment_for_document_type",
"reqd": 1
},
{
"fieldname": "source",
@@ -156,8 +161,8 @@
"link_fieldname": "payment"
}
],
"modified": "2025-02-21 18:29:55.436611",
"modified_by": "Administrator",
"modified": "2025-08-19 10:33:15.457678",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Payment",
"owner": "Administrator",
@@ -175,9 +180,11 @@
"write": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "billing_name"
}
"title_field": "billing_name",
"track_changes": 1
}

View File

@@ -3,9 +3,9 @@
import frappe
from frappe import _
from frappe.utils import add_days, nowdate
from frappe.email.doctype.email_template.email_template import get_email_template
from frappe.model.document import Document
from frappe.utils import add_days, nowdate
class LMSPayment(Document):
@@ -33,15 +33,42 @@ def send_payment_reminder():
)
for payment in incomplete_payments:
if has_paid_later(payment):
continue
if is_batch_sold_out(payment):
continue
send_mail(payment)
def has_paid_later(payment):
return frappe.db.exists(
"LMS Payment",
{
"member": payment.member,
"payment_received": 1,
"payment_for_document": payment.payment_for_document,
"payment_for_document_type": payment.payment_for_document_type,
},
)
def is_batch_sold_out(payment):
if payment.payment_for_document_type == "LMS Batch":
seat_count = frappe.get_cached_value("LMS Batch", payment.payment_for_document, "seat_count")
number_of_students = frappe.db.count("LMS Batch Enrollment", {"batch": payment.payment_for_document})
if seat_count <= number_of_students:
return True
return False
def send_mail(payment):
subject = _("Complete Your Enrollment - Don't miss out!")
template = "payment_reminder"
custom_template = frappe.db.get_single_value(
"LMS Settings", "payment_reminder_template"
)
custom_template = frappe.db.get_single_value("LMS Settings", "payment_reminder_template")
args = {
"billing_name": payment.billing_name,

View File

@@ -7,8 +7,17 @@
"engine": "InnoDB",
"field_order": [
"title",
"published",
"column_break_cwjx",
"enforce_course_order",
"column_break_mikl",
"section_break_vhhu",
"program_courses",
"program_members"
"program_members",
"section_break_pppe",
"course_count",
"column_break_qwhf",
"member_count"
],
"fields": [
{
@@ -30,12 +39,61 @@
"label": "Title",
"reqd": 1,
"unique": 1
},
{
"default": "0",
"fieldname": "published",
"fieldtype": "Check",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Published"
},
{
"default": "0",
"fieldname": "enforce_course_order",
"fieldtype": "Check",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Enforce Course Order"
},
{
"fieldname": "section_break_vhhu",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_cwjx",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_pppe",
"fieldtype": "Section Break"
},
{
"fieldname": "course_count",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Course Count"
},
{
"fieldname": "column_break_qwhf",
"fieldtype": "Column Break"
},
{
"fieldname": "member_count",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Member Count"
},
{
"fieldname": "column_break_mikl",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-11-28 22:06:16.742867",
"modified_by": "Administrator",
"modified": "2025-08-20 12:28:57.238902",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Program",
"naming_rule": "By fieldname",
@@ -76,10 +134,21 @@
"role": "Course Creator",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}
}

View File

@@ -10,6 +10,7 @@ class LMSProgram(Document):
def validate(self):
self.validate_program_courses()
self.validate_program_members()
self.update_count()
def validate_program_courses(self):
courses = [row.course for row in self.program_courses]
@@ -30,3 +31,13 @@ class LMSProgram(Document):
frappe.bold(next(iter(duplicates)))
)
)
def update_count(self):
course_count = len(self.program_courses)
member_count = len(self.program_members)
if self.course_count != course_count:
self.course_count = course_count
if self.member_count != member_count:
self.member_count = member_count

View File

@@ -4,7 +4,6 @@
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record depdendencies are recursively loaded
# Use these module variables to add/remove to/from that list

View File

@@ -27,16 +27,18 @@
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-11-18 12:43:46.800199",
"modified_by": "Administrator",
"modified": "2025-08-13 17:32:43.554055",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Program Course",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -35,16 +35,18 @@
"label": "Progress"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-11-21 12:51:31.882576",
"modified_by": "Administrator",
"modified": "2025-08-13 17:33:00.265037",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Program Member",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

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

View File

@@ -0,0 +1,136 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-06-18 15:02:36.198855",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"title",
"column_break_jlzi",
"language",
"section_break_tjwv",
"problem_statement",
"section_break_ftkh",
"test_cases"
],
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title",
"reqd": 1
},
{
"fieldname": "problem_statement",
"fieldtype": "Text Editor",
"in_list_view": 1,
"label": "Problem Statement",
"reqd": 1
},
{
"default": "Python",
"fieldname": "language",
"fieldtype": "Select",
"label": "Language",
"options": "Python\nJavaScript",
"reqd": 1
},
{
"fieldname": "column_break_jlzi",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_tjwv",
"fieldtype": "Section Break"
},
{
"fieldname": "section_break_ftkh",
"fieldtype": "Section Break"
},
{
"fieldname": "test_cases",
"fieldtype": "Table",
"label": "Test Cases",
"options": "LMS Test Case"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [
{
"link_doctype": "LMS Programming Exercise Submission",
"link_fieldname": "exercise"
}
],
"modified": "2025-06-24 14:42:27.463492",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Programming Exercise",
"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": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title"
}

View File

@@ -0,0 +1,15 @@
# Copyright (c) 2025, Frappe and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
class LMSProgrammingExercise(Document):
def validate(self):
self.validate_test_cases()
def validate_test_cases(self):
if not self.test_cases:
frappe.throw(_("At least one test case is required for the programming exercise."))

View File

@@ -0,0 +1,29 @@
# Copyright (c) 2025, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# 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 UnitTestLMSProgrammingExercise(UnitTestCase):
"""
Unit tests for LMSProgrammingExercise.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestLMSProgrammingExercise(IntegrationTestCase):
"""
Integration tests for LMSProgrammingExercise.
Use this class for testing interactions between multiple components.
"""
pass

View File

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

View File

@@ -0,0 +1,172 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-06-18 20:01:37.678342",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"exercise",
"exercise_title",
"status",
"column_break_jkjs",
"member",
"member_name",
"member_image",
"section_break_onmz",
"code",
"section_break_idyi",
"test_cases"
],
"fields": [
{
"fieldname": "exercise",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Exercise",
"options": "LMS Programming Exercise",
"reqd": 1
},
{
"fieldname": "member",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Member",
"options": "User",
"reqd": 1
},
{
"fetch_from": "member.full_name",
"fieldname": "member_name",
"fieldtype": "Data",
"label": "Member Name",
"read_only": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"label": "Status",
"options": "\nPassed\nFailed"
},
{
"fieldname": "column_break_jkjs",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_idyi",
"fieldtype": "Section Break"
},
{
"fieldname": "test_cases",
"fieldtype": "Table",
"label": "Test Cases",
"options": "LMS Test Case Submission"
},
{
"fieldname": "section_break_onmz",
"fieldtype": "Section Break"
},
{
"fieldname": "code",
"fieldtype": "Code",
"label": "Code",
"reqd": 1
},
{
"fetch_from": "exercise.title",
"fieldname": "exercise_title",
"fieldtype": "Data",
"label": "Exercise Title",
"read_only": 1
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach",
"label": "Member Image"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-06-24 14:42:08.288983",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Programming 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
},
{
"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": "Course Creator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"share": 1,
"write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [
{
"color": "Green",
"title": "Passed"
},
{
"color": "Red",
"title": "Failed"
}
],
"title_field": "member_name"
}

View File

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

View File

@@ -0,0 +1,29 @@
# Copyright (c) 2025, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# 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 UnitTestLMSProgrammingExerciseSubmission(UnitTestCase):
"""
Unit tests for LMSProgrammingExerciseSubmission.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestLMSProgrammingExerciseSubmission(IntegrationTestCase):
"""
Integration tests for LMSProgrammingExerciseSubmission.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -4,6 +4,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
from lms.lms.utils import has_course_instructor_role, has_course_moderator_role
@@ -71,9 +72,7 @@ def validate_possible_answer(question):
def update_question_title(question):
if not question.is_new():
question_rows = frappe.get_all(
"LMS Quiz Question", {"question": question.name}, pluck="name"
)
question_rows = frappe.get_all("LMS Quiz Question", {"question": question.name}, pluck="name")
for row in question_rows:
frappe.db.set_value("LMS Quiz Question", row, "question_detail", question.question)

View File

@@ -8,7 +8,7 @@ frappe.ui.form.on("LMS Quiz", {
frappe.ui.form.on("LMS Quiz Question", {
marks: function (frm) {
total_marks = 0;
let total_marks = 0;
frm.doc.questions.forEach((question) => {
total_marks += question.marks;
});

View File

@@ -17,8 +17,10 @@
"duration",
"section_break_tzbu",
"shuffle_questions",
"column_break_clsh",
"limit_questions_to",
"column_break_clsh",
"enable_negative_marking",
"marks_to_cut",
"section_break_sbjx",
"questions",
"section_break_3",
@@ -134,12 +136,31 @@
"fieldname": "duration",
"fieldtype": "Data",
"label": "Duration (in minutes)"
},
{
"default": "0",
"fieldname": "enable_negative_marking",
"fieldtype": "Check",
"label": "Enable Negative Marking"
},
{
"default": "1",
"depends_on": "enable_negative_marking",
"fieldname": "marks_to_cut",
"fieldtype": "Int",
"label": "Marks To Cut"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-06 11:02:09.749207",
"modified_by": "Administrator",
"links": [
{
"link_doctype": "LMS Quiz Submission",
"link_fieldname": "quiz"
}
],
"modified": "2025-06-27 20:00:15.660323",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Quiz",
"owner": "Administrator",
@@ -190,10 +211,11 @@
"share": 1
}
],
"row_format": "Dynamic",
"show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}
}

View File

@@ -2,21 +2,21 @@
# For license information, please see license.txt
import json
import frappe
import re
from binascii import Error as BinasciiError
import frappe
from frappe import _, safe_decode
from frappe.core.doctype.file.utils import get_random_filename
from frappe.model.document import Document
from frappe.utils import cstr, comma_and, cint
from frappe.utils import cint, comma_and, cstr
from frappe.utils.file_manager import safe_b64decode
from fuzzywuzzy import fuzz
from lms.lms.doctype.course_lesson.course_lesson import save_progress
from lms.lms.utils import (
generate_slug,
has_course_moderator_role,
has_course_instructor_role,
)
from binascii import Error as BinasciiError
from frappe.utils.file_manager import safe_b64decode
from frappe.core.doctype.file.utils import get_random_filename
class LMSQuiz(Document):
@@ -30,15 +30,11 @@ class LMSQuiz(Document):
questions = [row.question for row in self.questions]
rows = [i + 1 for i, x in enumerate(questions) if questions.count(x) > 1]
if len(rows):
frappe.throw(
_("Rows {0} have the duplicate questions.").format(frappe.bold(comma_and(rows)))
)
frappe.throw(_("Rows {0} have the duplicate questions.").format(frappe.bold(comma_and(rows))))
def validate_limit(self):
if self.limit_questions_to and cint(self.limit_questions_to) >= len(self.questions):
frappe.throw(
_("Limit cannot be greater than or equal to the number of questions in the quiz.")
)
frappe.throw(_("Limit cannot be greater than or equal to the number of questions in the quiz."))
if self.limit_questions_to and cint(self.limit_questions_to) < len(self.questions):
marks = [question.marks for question in self.questions]
@@ -46,6 +42,11 @@ class LMSQuiz(Document):
frappe.throw(_("All questions should have the same marks if the limit is set."))
def calculate_total_marks(self):
if len(self.questions) == 0:
self.total_marks = 0
self.passing_percentage = 100
return
if self.limit_questions_to:
self.total_marks = sum(
question.marks for question in self.questions[: cint(self.limit_questions_to)]
@@ -98,24 +99,53 @@ def set_total_marks(questions):
@frappe.whitelist()
def quiz_summary(quiz, results):
score = 0
results = results and json.loads(results)
is_open_ended = False
percentage = 0
quiz_details = frappe.db.get_value(
"LMS Quiz",
quiz,
["total_marks", "passing_percentage", "lesson", "course"],
[
"name",
"total_marks",
"passing_percentage",
"lesson",
"course",
"enable_negative_marking",
"marks_to_cut",
],
as_dict=1,
)
data = process_results(results, quiz_details)
results = data["results"]
score = data["score"]
is_open_ended = data["is_open_ended"]
score_out_of = quiz_details.total_marks
percentage = (score / score_out_of) * 100 if score_out_of else 0
submission = create_submission(quiz, results, score_out_of, quiz_details.passing_percentage)
save_progress_after_quiz(quiz_details, percentage)
return {
"score": score,
"score_out_of": score_out_of,
"submission": submission.name,
"pass": percentage == quiz_details.passing_percentage,
"percentage": percentage,
"is_open_ended": is_open_ended,
}
def process_results(results, quiz_details):
score = 0
is_open_ended = False
for result in results:
question_details = frappe.db.get_value(
"LMS Quiz Question",
{"parent": quiz, "question": result["question_name"]},
{"parent": quiz_details.name, "question": result["question_name"]},
["question", "marks", "question_detail", "type"],
as_dict=1,
)
@@ -125,55 +155,32 @@ def quiz_summary(quiz, results):
result["marks_out_of"] = question_details.marks
if question_details.type != "Open Ended":
correct = result["is_correct"][0]
for point in result["is_correct"]:
correct = correct and point
result["is_correct"] = correct
if len(result["is_correct"]) > 0:
correct = result["is_correct"][0]
for point in result["is_correct"]:
correct = correct and point
result["is_correct"] = correct
else:
result["is_correct"] = 0
if correct:
marks = question_details.marks
else:
marks = -quiz_details.marks_to_cut if quiz_details.enable_negative_marking else 0
marks = question_details.marks if correct else 0
result["marks"] = marks
score += marks
else:
result["is_correct"] = 0
is_open_ended = True
percentage = (score / score_out_of) * 100
result["answer"] = re.sub(
r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"]
)
submission = frappe.new_doc("LMS Quiz Submission")
# Score and percentage are calculated by the controller function
submission.update(
{
"doctype": "LMS Quiz Submission",
"quiz": quiz,
"result": results,
"score": 0,
"score_out_of": score_out_of,
"member": frappe.session.user,
"percentage": 0,
"passing_percentage": quiz_details.passing_percentage,
}
)
submission.save(ignore_permissions=True)
if (
percentage >= quiz_details.passing_percentage
and quiz_details.lesson
and quiz_details.course
):
save_progress(quiz_details.lesson, quiz_details.course)
elif not quiz_details.passing_percentage:
save_progress(quiz_details.lesson, quiz_details.course)
result["is_correct"] = 0
result["answer"] = re.sub(
r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"]
)
return {
"results": results,
"score": score,
"score_out_of": score_out_of,
"submission": submission.name,
"pass": percentage == quiz_details.passing_percentage,
"percentage": percentage,
"is_open_ended": is_open_ended,
}
@@ -221,6 +228,32 @@ def get_corrupted_image_msg():
return _("Image: Corrupted Data Stream")
def create_submission(quiz, results, score_out_of, passing_percentage):
submission = frappe.new_doc("LMS Quiz Submission")
# Score and percentage are calculated by the controller function
submission.update(
{
"doctype": "LMS Quiz Submission",
"quiz": quiz,
"result": results,
"score": 0,
"score_out_of": score_out_of,
"member": frappe.session.user,
"percentage": 0,
"passing_percentage": passing_percentage,
}
)
submission.save(ignore_permissions=True)
return submission
def save_progress_after_quiz(quiz_details, percentage):
if percentage >= quiz_details.passing_percentage and quiz_details.lesson and quiz_details.course:
save_progress(quiz_details.lesson, quiz_details.course)
elif not quiz_details.passing_percentage:
save_progress(quiz_details.lesson, quiz_details.course)
@frappe.whitelist()
def get_question_details(question):
if frappe.db.exists("LMS Quiz Question", question):

View File

@@ -10,9 +10,9 @@ import frappe
class TestLMSQuiz(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
frappe.get_doc(
{"doctype": "LMS Quiz", "title": "Test Quiz", "passing_percentage": 90}
).save(ignore_permissions=True)
frappe.get_doc({"doctype": "LMS Quiz", "title": "Test Quiz", "passing_percentage": 90}).save(
ignore_permissions=True
)
def test_with_multiple_options(self):
question = frappe.new_doc("LMS Question")

View File

@@ -2,10 +2,10 @@
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
from frappe.utils import cint
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 cint
class LMSQuizSubmission(Document):

View File

@@ -20,10 +20,11 @@ frappe.ui.form.on("LMS Settings", {
frm.get_field("payments_app_is_not_installed").html(`
<div class="alert alert-warning">
Please install the
<a target="_blank" style="color: var(--alert-text-warning); background: var(--alert-bg-warning);" href="https://frappecloud.com/marketplace/apps/payments">
Payments app
</a>
to enable payment gateway.
<a target="_blank" style="text-decoration: underline; color: var(--alert-text-warning); background: var(--alert-bg-warning);" href="https://frappecloud.com/marketplace/apps/payments">Payments app</a>
to enable payment gateway. Refer to the
<a target="_blank" style="text-decoration: underline; color: var(--alert-text-warning); background: var(--alert-bg-warning);" href="https://docs.frappe.io/learning/setting-up-payment-gateway">Documentation</a>
for more information.
</div>
`);
},
});

View File

@@ -8,10 +8,11 @@
"general_tab",
"default_home",
"send_calendar_invite_for_evaluations",
"is_onboarding_complete",
"persona_captured",
"column_break_zdel",
"allow_guest_access",
"enable_learning_paths",
"prevent_skipping_videos",
"column_break_bjis",
"unsplash_access_key",
"livecode_url",
"section_break_szgq",
@@ -27,13 +28,16 @@
"signup_settings_tab",
"signup_settings_section",
"column_break_9",
"custom_signup_content",
"user_category",
"disable_signup",
"custom_signup_content",
"sidebar_tab",
"items_in_sidebar_section",
"courses",
"batches",
"certified_participants",
"certified_members",
"programming_exercises",
"column_break_exdz",
"jobs",
"statistics",
@@ -58,15 +62,18 @@
"certification_template",
"batch_confirmation_template",
"column_break_uwsp",
"assignment_submission_template",
"payment_reminder_template"
"payment_reminder_template",
"seo_tab",
"meta_description",
"meta_image",
"column_break_xijv",
"meta_keywords"
],
"fields": [
{
"default": "https://livecode.dev.fossunited.org",
"fieldname": "livecode_url",
"fieldtype": "Data",
"hidden": 1,
"label": "LiveCode URL"
},
{
@@ -104,14 +111,7 @@
"default": "0",
"fieldname": "user_category",
"fieldtype": "Check",
"label": "Ask User Category during Signup"
},
{
"default": "0",
"fieldname": "is_onboarding_complete",
"fieldtype": "Check",
"label": "Is Onboarding Complete",
"read_only": 1
"label": "Identify User Category"
},
{
"default": "0",
@@ -243,12 +243,6 @@
"fieldtype": "Tab Break",
"label": "Email Templates"
},
{
"fieldname": "assignment_submission_template",
"fieldtype": "Link",
"label": "Assignment Submission Template",
"options": "Email Template"
},
{
"fieldname": "column_break_uwsp",
"fieldtype": "Column Break"
@@ -285,6 +279,7 @@
"default": "1",
"fieldname": "certified_participants",
"fieldtype": "Check",
"hidden": 1,
"label": "Certified Participants"
},
{
@@ -343,12 +338,6 @@
"fieldtype": "HTML",
"label": "Payments app is not installed"
},
{
"default": "0",
"fieldname": "enable_learning_paths",
"fieldtype": "Check",
"label": "Enable Learning Paths"
},
{
"fieldname": "general_tab",
"fieldtype": "Tab Break",
@@ -365,13 +354,76 @@
"fieldtype": "Link",
"label": "Payment Reminder Template",
"options": "Email Template"
},
{
"default": "0",
"fieldname": "disable_signup",
"fieldtype": "Check",
"label": "Disable Signup"
},
{
"fieldname": "seo_tab",
"fieldtype": "Tab Break",
"label": "SEO"
},
{
"description": "This description will be shown on lists and pages without meta description",
"fieldname": "meta_description",
"fieldtype": "Small Text",
"label": "Meta Description"
},
{
"description": "This image will be shown on lists and pages that don't have an image by default",
"fieldname": "meta_image",
"fieldtype": "Attach Image",
"label": "Meta Image"
},
{
"fieldname": "column_break_xijv",
"fieldtype": "Column Break"
},
{
"description": "Common keywords that will be used for all pages",
"fieldname": "meta_keywords",
"fieldtype": "Small Text",
"label": "Meta Keywords"
},
{
"default": "0",
"fieldname": "persona_captured",
"fieldtype": "Check",
"label": "Persona Captured",
"read_only": 1
},
{
"default": "0",
"fieldname": "certified_members",
"fieldtype": "Check",
"label": "Certified Members"
},
{
"default": "0",
"fieldname": "prevent_skipping_videos",
"fieldtype": "Check",
"label": "Prevent Skipping Videos"
},
{
"fieldname": "column_break_bjis",
"fieldtype": "Column Break"
},
{
"default": "1",
"fieldname": "programming_exercises",
"fieldtype": "Check",
"label": "Programming Exercises"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-02-11 11:29:43.412897",
"modified_by": "Administrator",
"modified": "2025-08-12 16:47:49.983018",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Settings",
"owner": "Administrator",
@@ -392,10 +444,21 @@
"read": 1,
"role": "LMS Student",
"share": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Moderator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -10,6 +10,7 @@ from frappe.utils import get_url_to_list
class LMSSettings(Document):
def validate(self):
self.validate_google_settings()
self.validate_signup()
def validate_google_settings(self):
if self.send_calendar_invite_for_evaluations:
@@ -40,6 +41,10 @@ class LMSSettings(Document):
)
)
def validate_signup(self):
if self.has_value_changed("disable_signup"):
frappe.db.set_single_value("Website Settings", "disable_signup", self.disable_signup)
@frappe.whitelist()
def check_payments_app():

View File

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

View File

@@ -0,0 +1,45 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-06-18 16:12:10.010416",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"input",
"column_break_zkvg",
"expected_output"
],
"fields": [
{
"fieldname": "input",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Input"
},
{
"fieldname": "column_break_zkvg",
"fieldtype": "Column Break"
},
{
"fieldname": "expected_output",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Expected Output",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-06-20 12:57:19.186644",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Test Case",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

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

View File

@@ -0,0 +1,29 @@
# Copyright (c) 2025, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# 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 UnitTestLMSTestCase(UnitTestCase):
"""
Unit tests for LMSTestCase.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestLMSTestCase(IntegrationTestCase):
"""
Integration tests for LMSTestCase.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -0,0 +1,63 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-06-18 20:05:03.467705",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"input",
"expected_output",
"column_break_bsjs",
"output",
"status"
],
"fields": [
{
"fieldname": "input",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Input"
},
{
"fieldname": "expected_output",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Expected Output",
"reqd": 1
},
{
"fieldname": "column_break_bsjs",
"fieldtype": "Column Break"
},
{
"fieldname": "output",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Output",
"reqd": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "Passed\nFailed",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-06-24 11:23:13.803159",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Test Case Submission",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

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

View File

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

View File

@@ -0,0 +1,163 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-06-30 13:00:22.655432",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"lesson",
"chapter",
"course",
"column_break_tmwj",
"member",
"member_name",
"member_image",
"member_username",
"section_break_fywc",
"source",
"column_break_uuyv",
"watch_time"
],
"fields": [
{
"fieldname": "lesson",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Lesson",
"options": "Course Lesson",
"reqd": 1
},
{
"fetch_from": "lesson.chapter",
"fieldname": "chapter",
"fieldtype": "Link",
"label": "Chapter",
"options": "Course Chapter",
"read_only": 1
},
{
"fetch_from": "lesson.course",
"fieldname": "course",
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Course",
"options": "LMS Course",
"read_only": 1
},
{
"fieldname": "column_break_tmwj",
"fieldtype": "Column Break"
},
{
"fieldname": "member",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Member",
"options": "User",
"reqd": 1
},
{
"fetch_from": "member.full_name",
"fieldname": "member_name",
"fieldtype": "Data",
"label": "Member Name"
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach Image",
"label": "Member Image"
},
{
"fetch_from": "member.username",
"fieldname": "member_username",
"fieldtype": "Data",
"label": "Member Username"
},
{
"fieldname": "section_break_fywc",
"fieldtype": "Section Break"
},
{
"fieldname": "source",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Source",
"reqd": 1
},
{
"fieldname": "column_break_uuyv",
"fieldtype": "Column Break"
},
{
"fieldname": "watch_time",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Watch Time",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-07-30 14:38:52.555010",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Video Watch Duration",
"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
},
{
"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": "Course Creator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

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

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 IntegrationTestLMSVideoWatchDuration(IntegrationTestCase):
"""
Integration tests for LMSVideoWatchDuration.
Use this class for testing interactions between multiple components.
"""
pass

View File

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

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