mirror of
https://github.com/frappe/lms.git
synced 2026-05-02 13:39:31 +03:00
Merge pull request #2253 from frappe/main-hotfix
chore: merge 'main-hotfix' into 'main'
This commit is contained in:
+19
-3
@@ -7,6 +7,7 @@ def after_install():
|
||||
create_batch_source()
|
||||
give_discussions_permission()
|
||||
give_user_list_permission()
|
||||
give_event_permission()
|
||||
|
||||
|
||||
def after_sync():
|
||||
@@ -189,8 +190,22 @@ def give_user_list_permission():
|
||||
create_role(doctype, "System Manager", 1)
|
||||
|
||||
|
||||
def create_role(doctype, role, permlevel):
|
||||
def give_event_permission():
|
||||
doctype = "Event"
|
||||
roles = ["Moderator", "Batch Evaluator"]
|
||||
for role in roles:
|
||||
permlevel = 0
|
||||
create_role(doctype, role, permlevel, 1, 1)
|
||||
create_role(doctype, "System Manager", 0, 1, 1)
|
||||
|
||||
|
||||
def create_role(doctype, role, permlevel, write=0, create=0):
|
||||
if not frappe.db.exists("Custom DocPerm", {"parent": doctype, "role": role, "permlevel": permlevel}):
|
||||
if not write and not create:
|
||||
if role in ["Moderator", "System Manager"]:
|
||||
write = 1
|
||||
if role == "Moderator":
|
||||
create = 1
|
||||
doc = frappe.new_doc("Custom DocPerm")
|
||||
doc.update(
|
||||
{
|
||||
@@ -198,8 +213,9 @@ def create_role(doctype, role, permlevel):
|
||||
"parent": doctype,
|
||||
"role": role,
|
||||
"read": 1,
|
||||
"write": 1 if role in ["Moderator", "System Manager"] else 0,
|
||||
"create": 1 if role == "Moderator" else 0,
|
||||
"select": 1,
|
||||
"write": write,
|
||||
"create": create,
|
||||
"permlevel": permlevel,
|
||||
}
|
||||
)
|
||||
|
||||
+75
-29
@@ -1429,43 +1429,43 @@ def save_role(user: str, role: str, value: int):
|
||||
if role not in LMS_ROLES:
|
||||
frappe.throw(_("You do not have permission to modify this role."), frappe.PermissionError)
|
||||
|
||||
if role == "Batch Evaluator":
|
||||
return save_evaluator_role(user, value)
|
||||
|
||||
if cint(value):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Has Role",
|
||||
"parent": user,
|
||||
"role": role,
|
||||
"parenttype": "User",
|
||||
"parentfield": "roles",
|
||||
}
|
||||
)
|
||||
doc.save(ignore_permissions=True)
|
||||
if not frappe.db.exists("Has Role", {"parent": user, "role": role}):
|
||||
doc = frappe.new_doc("Has Role")
|
||||
doc.parent = user
|
||||
doc.parenttype = "User"
|
||||
doc.parentfield = "roles"
|
||||
doc.role = role
|
||||
doc.save(ignore_permissions=True)
|
||||
else:
|
||||
frappe.db.delete("Has Role", {"parent": user, "role": role})
|
||||
frappe.clear_cache(user=user)
|
||||
return True
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_an_evaluator(email: str):
|
||||
def save_evaluator_role(user: str, value: int):
|
||||
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
|
||||
if cint(value):
|
||||
if not frappe.db.exists("Has Role", {"parent": user, "role": "Batch Evaluator"}):
|
||||
doc = frappe.new_doc("Has Role")
|
||||
doc.parent = user
|
||||
doc.parenttype = "User"
|
||||
doc.parentfield = "roles"
|
||||
doc.role = "Batch Evaluator"
|
||||
doc.save(ignore_permissions=True)
|
||||
if not frappe.db.exists("Course Evaluator", {"evaluator": user}):
|
||||
doc = frappe.new_doc("Course Evaluator")
|
||||
doc.evaluator = user
|
||||
doc.save(ignore_permissions=True)
|
||||
else:
|
||||
frappe.db.delete("Has Role", {"parent": user, "role": "Batch Evaluator"})
|
||||
if frappe.db.exists("Course Evaluator", {"evaluator": user}):
|
||||
frappe.db.delete("Course Evaluator", {"evaluator": user})
|
||||
frappe.clear_cache(user=user)
|
||||
return True
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -2307,3 +2307,49 @@ def clear_demo_data():
|
||||
frappe.delete_doc("User", user, ignore_permissions=True)
|
||||
|
||||
frappe.db.set_single_value("LMS Settings", "demo_data_present", False)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def search_users_by_role(txt: str = "", roles: str | list | None = None, page_length: int = 10):
|
||||
"""Returns users with `roles` in search_link format"""
|
||||
frappe.only_for(["Moderator", "Course Creator", "Batch Evaluator"])
|
||||
if not roles:
|
||||
return []
|
||||
|
||||
if isinstance(roles, str):
|
||||
roles = json.loads(roles)
|
||||
|
||||
invalid_roles = set(roles) - set(LMS_ROLES)
|
||||
if invalid_roles:
|
||||
frappe.throw(_("Cannot search for roles: {0}").format(", ".join(invalid_roles)))
|
||||
|
||||
users_with_roles = frappe.get_all(
|
||||
"Has Role",
|
||||
filters={"role": ["in", roles], "parenttype": "User"},
|
||||
pluck="parent",
|
||||
distinct=True,
|
||||
)
|
||||
|
||||
if not users_with_roles:
|
||||
return []
|
||||
|
||||
results = frappe.get_all(
|
||||
"User",
|
||||
filters=[
|
||||
["name", "in", users_with_roles],
|
||||
["name", "not in", ["Administrator", "Guest"]],
|
||||
["enabled", "=", 1],
|
||||
],
|
||||
or_filters=[
|
||||
["full_name", "like", f"%{txt}%"],
|
||||
["name", "like", f"%{txt}%"],
|
||||
],
|
||||
fields=["name", "full_name"],
|
||||
limit_page_length=cint(page_length),
|
||||
order_by="full_name asc",
|
||||
)
|
||||
|
||||
return [
|
||||
{"value": r.name, "description": r.full_name or r.name, "label": r.full_name or r.name}
|
||||
for r in results
|
||||
]
|
||||
|
||||
@@ -17,6 +17,11 @@ class CourseEvaluator(Document):
|
||||
self.validate_time_slots()
|
||||
self.validate_unavailability()
|
||||
|
||||
def on_trash(self):
|
||||
roles = frappe.get_roles(self.evaluator)
|
||||
if "Batch Evaluator" in roles:
|
||||
frappe.get_doc("User", self.evaluator).remove_roles("Batch Evaluator")
|
||||
|
||||
def validate_evaluator_role(self):
|
||||
roles = frappe.get_roles(self.evaluator)
|
||||
if "Batch Evaluator" not in roles:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Copyright (c) 2022, Frappe and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, format_time, getdate
|
||||
|
||||
from lms.lms.api import save_role
|
||||
from lms.lms.doctype.course_evaluator.course_evaluator import get_schedule, get_schedule_range_end_date
|
||||
from lms.lms.test_helpers import BaseTestUtils
|
||||
|
||||
@@ -63,3 +65,59 @@ class TestCourseEvaluator(BaseTestUtils):
|
||||
for row in schedule:
|
||||
schedule_date = getdate(row.get("date"))
|
||||
self.assertFalse(unavailable_from < schedule_date < unavailable_to)
|
||||
|
||||
|
||||
class TestEvaluatorRoleCRUD(BaseTestUtils):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.admin = self._create_user(
|
||||
"frappe@example.com", "Frappe", "Admin", ["Moderator", "Course Creator", "Batch Evaluator"]
|
||||
)
|
||||
self.test_user = self._create_user("eval_test@example.com", "Eval", "Tester", ["LMS Student"])
|
||||
|
||||
def _has_batch_evaluator_role(self, user):
|
||||
return frappe.db.exists("Has Role", {"parent": user, "role": "Batch Evaluator"})
|
||||
|
||||
def _has_course_evaluator(self, user):
|
||||
return frappe.db.exists("Course Evaluator", {"evaluator": user})
|
||||
|
||||
def test_add_evaluator_role_creates_both(self):
|
||||
"""save_role with value=1 should create Has Role AND Course Evaluator."""
|
||||
frappe.set_user("frappe@example.com")
|
||||
save_role(self.test_user.email, "Batch Evaluator", 1)
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
self.assertTrue(self._has_batch_evaluator_role(self.test_user.email))
|
||||
self.assertTrue(self._has_course_evaluator(self.test_user.email))
|
||||
|
||||
self.cleanup_items.append(("Course Evaluator", self.test_user.email))
|
||||
|
||||
def test_remove_evaluator_role_removes_both(self):
|
||||
"""save_role with value=0 should remove Has Role AND Course Evaluator."""
|
||||
frappe.set_user("frappe@example.com")
|
||||
save_role(self.test_user.email, "Batch Evaluator", 1)
|
||||
save_role(self.test_user.email, "Batch Evaluator", 0)
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
self.assertFalse(self._has_batch_evaluator_role(self.test_user.email))
|
||||
self.assertFalse(self._has_course_evaluator(self.test_user.email))
|
||||
|
||||
def test_remove_evaluator_role_no_error_when_missing(self):
|
||||
"""Removing role that doesn't exist should not raise an error."""
|
||||
frappe.set_user("frappe@example.com")
|
||||
save_role(self.test_user.email, "Batch Evaluator", 0)
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
self.assertFalse(self._has_batch_evaluator_role(self.test_user.email))
|
||||
|
||||
def test_reject_non_lms_role(self):
|
||||
"""Assigning a role outside LMS_ROLES should raise PermissionError."""
|
||||
frappe.set_user("frappe@example.com")
|
||||
self.assertRaises(frappe.PermissionError, save_role, self.test_user.email, "System Manager", 1)
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def test_non_moderator_cannot_save_role(self):
|
||||
"""[A non-moderator user should not be able to assign roles.]"""
|
||||
frappe.set_user(self.test_user.email)
|
||||
self.assertRaises(frappe.PermissionError, save_role, self.test_user.email, "Course Creator", 1)
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
@@ -151,7 +151,6 @@ def process_results(results: list, quiz_details: dict):
|
||||
["question", "marks", "question_detail", "type"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
result["question_name"] = question_details.question
|
||||
result["question"] = question_details.question_detail
|
||||
result["marks_out_of"] = question_details.marks
|
||||
@@ -165,12 +164,13 @@ def process_results(results: list, quiz_details: dict):
|
||||
result["marks"] = -quiz_details.marks_to_cut if quiz_details.enable_negative_marking else 0
|
||||
|
||||
score += result["marks"]
|
||||
result["is_correct"] = 1 if correct else 0
|
||||
|
||||
else:
|
||||
is_open_ended = True
|
||||
result["is_correct"] = 0
|
||||
result["answer"] = re.sub(
|
||||
r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"]
|
||||
r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"][0]
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -263,10 +263,12 @@ def create_submission(quiz: str, results: list, score_out_of: int, passing_perce
|
||||
|
||||
|
||||
def save_progress_after_quiz(quiz_details: dict, percentage: float):
|
||||
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)
|
||||
if not quiz_details.lesson or not quiz_details.course:
|
||||
return
|
||||
|
||||
if quiz_details.passing_percentage and percentage < quiz_details.passing_percentage:
|
||||
return
|
||||
save_progress(quiz_details.lesson, quiz_details.course)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -445,7 +445,7 @@
|
||||
"label": "Contact Us URL"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"default": "1",
|
||||
"fieldname": "certifications",
|
||||
"fieldtype": "Check",
|
||||
"label": "Certifications"
|
||||
@@ -512,7 +512,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-10 18:18:51.733955",
|
||||
"modified": "2026-03-18 15:32:56.259783",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Settings",
|
||||
|
||||
+24
-2
@@ -47,8 +47,8 @@ class TestLMSAPI(BaseTestUtils):
|
||||
for quiz in progress.quizzes:
|
||||
self.assertEqual(quiz.quiz, self.quiz.name)
|
||||
self.assertEqual(quiz.quiz_title, self.quiz.title)
|
||||
self.assertEqual(quiz.score, 12)
|
||||
self.assertEqual(quiz.percentage, 80)
|
||||
self.assertEqual(quiz.score, 10)
|
||||
self.assertEqual(quiz.percentage, 66)
|
||||
|
||||
self.assertEqual(len(progress.assignments), 1)
|
||||
for assignment in progress.assignments:
|
||||
@@ -61,3 +61,25 @@ class TestLMSAPI(BaseTestUtils):
|
||||
self.assertEqual(exercise.exercise, self.programming_exercise.name)
|
||||
self.assertEqual(exercise.exercise_title, self.programming_exercise.title)
|
||||
self.assertEqual(exercise.status, "Passed")
|
||||
|
||||
def test_quiz_submission(self):
|
||||
submission = frappe.get_all(
|
||||
"LMS Quiz Submission", filters={"quiz": self.quiz.name, "member": self.student1.name}
|
||||
)
|
||||
self.assertEqual(len(submission), 1)
|
||||
submission = submission[0]
|
||||
submission = frappe.get_doc("LMS Quiz Submission", submission.name)
|
||||
|
||||
self.assertEqual(submission.score, 10)
|
||||
self.assertEqual(submission.score_out_of, 15)
|
||||
self.assertEqual(submission.percentage, 66)
|
||||
self.assertEqual(submission.passing_percentage, 70)
|
||||
self.assertEqual(len(submission.result), 3)
|
||||
for index, result in enumerate(submission.result):
|
||||
self.assertEqual(result.question_name, self.quiz.questions[index].question)
|
||||
self.assertEqual(
|
||||
result.answer,
|
||||
self.questions[index].option_1 if index % 2 == 0 else self.questions[index].option_2,
|
||||
)
|
||||
self.assertEqual(result.is_correct, 1 if index % 2 == 0 else 0)
|
||||
self.assertEqual(result.marks, 5 if index % 2 == 0 else 0)
|
||||
|
||||
+17
-21
@@ -1,8 +1,11 @@
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe.tests import UnitTestCase
|
||||
from frappe.utils import add_days, nowdate
|
||||
|
||||
from lms.lms.doctype.lms_certificate.lms_certificate import get_default_certificate_template
|
||||
from lms.lms.doctype.lms_quiz.lms_quiz import submit_quiz
|
||||
|
||||
|
||||
class BaseTestUtils(UnitTestCase):
|
||||
@@ -267,7 +270,7 @@ class BaseTestUtils(UnitTestCase):
|
||||
}
|
||||
)
|
||||
question.save()
|
||||
self.cleanup_items.append(("LMS Quiz Question", question.name))
|
||||
self.cleanup_items.append(("LMS Question", question.name))
|
||||
questions.append(question)
|
||||
return questions
|
||||
|
||||
@@ -450,29 +453,22 @@ class BaseTestUtils(UnitTestCase):
|
||||
existing = frappe.db.exists("LMS Quiz Submission", {"quiz": self.quiz.name, "member": member})
|
||||
if existing:
|
||||
return frappe.get_doc("LMS Quiz Submission", existing)
|
||||
submission = frappe.new_doc("LMS Quiz Submission")
|
||||
submission.update(
|
||||
{
|
||||
"quiz": self.quiz.name,
|
||||
"member": member,
|
||||
"score_out_of": self.quiz.total_marks,
|
||||
"passing_percentage": self.quiz.passing_percentage,
|
||||
}
|
||||
)
|
||||
|
||||
for question in self.questions:
|
||||
submission.append(
|
||||
"result",
|
||||
frappe.session.user = member
|
||||
results = []
|
||||
for index, question in enumerate(self.questions):
|
||||
results.append(
|
||||
{
|
||||
"question": question.name,
|
||||
"marks": 4,
|
||||
"marks_out_of": 5,
|
||||
},
|
||||
"question_name": question.name,
|
||||
"answer": [question.option_1 if index % 2 == 0 else question.option_2],
|
||||
}
|
||||
)
|
||||
|
||||
submission.insert()
|
||||
self.cleanup_items.append(("LMS Quiz Submission", submission.name))
|
||||
return submission
|
||||
submit_quiz(self.quiz.name, json.dumps(results))
|
||||
submission = frappe.db.get_value(
|
||||
"LMS Quiz Submission", {"quiz": self.quiz.name, "member": member}, "name"
|
||||
)
|
||||
self.cleanup_items.append(("LMS Quiz Submission", submission))
|
||||
frappe.session.user = "Administrator"
|
||||
|
||||
def _create_assignment_submission(self, member):
|
||||
existing = frappe.db.exists(
|
||||
|
||||
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+321
-267
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+324
-270
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+321
-267
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+320
-266
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -120,4 +120,6 @@ lms.patches.v2_0.share_enrollment
|
||||
lms.patches.v2_0.give_user_list_permission #11-02-2026
|
||||
lms.patches.v2_0.rename_badge_assignment_event
|
||||
lms.patches.v2_0.enable_allow_job_posting
|
||||
lms.patches.v2_0.set_conferencing_provider_for_zoom
|
||||
lms.patches.v2_0.set_conferencing_provider_for_zoom
|
||||
lms.patches.v2_0.sync_evaluator_roles
|
||||
lms.patches.v2_0.give_event_permission #10-03-2026
|
||||
@@ -0,0 +1,5 @@
|
||||
from lms.install import give_event_permission
|
||||
|
||||
|
||||
def execute():
|
||||
give_event_permission()
|
||||
@@ -0,0 +1,31 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
evaluator_users = frappe.get_all("Course Evaluator", pluck="evaluator")
|
||||
|
||||
# Add Batch Evaluator role to all Course Evaluator users
|
||||
for user in evaluator_users:
|
||||
if not frappe.db.exists("Has Role", {"parent": user, "role": "Batch Evaluator"}):
|
||||
doc = frappe.new_doc("Has Role")
|
||||
doc.parent = user
|
||||
doc.parenttype = "User"
|
||||
doc.parentfield = "roles"
|
||||
doc.role = "Batch Evaluator"
|
||||
doc.save(ignore_permissions=True)
|
||||
|
||||
# Remove Batch Evaluator role from users who are not in Course Evaluator
|
||||
stale_users = frappe.get_all(
|
||||
"Has Role",
|
||||
filters={
|
||||
"role": "Batch Evaluator",
|
||||
"parent": ["not in", evaluator_users or [""]],
|
||||
"parenttype": "User",
|
||||
},
|
||||
pluck="parent",
|
||||
)
|
||||
for user in stale_users:
|
||||
frappe.db.delete("Has Role", {"parent": user, "role": "Batch Evaluator"})
|
||||
|
||||
for user in set(evaluator_users + stale_users):
|
||||
frappe.clear_cache(user=user)
|
||||
Reference in New Issue
Block a user