Merge branch 'develop' of https://github.com/frappe/lms into issues-172

This commit is contained in:
Jannat Patel
2026-01-19 17:04:35 +05:30
15 changed files with 168 additions and 59 deletions

View File

@@ -216,7 +216,7 @@ const video_link = computed(() => {
function enrollStudent() { function enrollStudent() {
if (!user.data) { if (!user.data) {
toast.success(__('You need to login first to enroll for this course')) toast.warning(__('You need to login first to enroll for this course'))
setTimeout(() => { setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}` window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 500) }, 500)

View File

@@ -107,7 +107,11 @@
v-model:reloadLiveClasses="liveClasses" v-model:reloadLiveClasses="liveClasses"
/> />
<LiveClassAttendance v-model="showAttendance" :live_class="attendanceFor" /> <LiveClassAttendance
v-if="showAttendance"
v-model="showAttendance"
:live_class="attendanceFor"
/>
</template> </template>
<script setup> <script setup>
import { createListResource, Button, Tooltip } from 'frappe-ui' import { createListResource, Button, Tooltip } from 'frappe-ui'

View File

@@ -22,7 +22,10 @@
</div> </div>
</Tooltip> </Tooltip>
<Tooltip :text="__('Course')"> <Tooltip :text="__('Course')">
<div class="flex items-center space-x-2 w-fit"> <div
class="flex space-x-2 w-fit cursor-pointer"
@click="openLink('course', event.course)"
>
<BookOpen class="h-4 w-4 stroke-1.5" /> <BookOpen class="h-4 w-4 stroke-1.5" />
<span> <span>
{{ event.course_title }} {{ event.course_title }}
@@ -30,7 +33,10 @@
</div> </div>
</Tooltip> </Tooltip>
<Tooltip v-if="event.batch_title" :text="__('Batch')"> <Tooltip v-if="event.batch_title" :text="__('Batch')">
<div class="flex items-center space-x-2 w-fit"> <div
class="flex space-x-2 w-fit cursor-pointer"
@click="openLink('batch', event.batch_name)"
>
<Users class="h-4 w-4 stroke-1.5" /> <Users class="h-4 w-4 stroke-1.5" />
<span> <span>
{{ event.batch_title }} {{ event.batch_title }}
@@ -334,7 +340,7 @@ const certificateDetails = createResource({
} }
}, },
onError(err) { onError(err) {
certificate.template = defaultTemplate.data.value certificate.template = defaultTemplate.data?.value
}, },
auto: false, auto: false,
}) })
@@ -377,6 +383,16 @@ const openCertificate = (certificate) => {
) )
} }
const openLink = (type, name) => {
let url = ''
if (type === 'course') {
url = `/lms/courses/${name}`
} else if (type === 'batch') {
url = `/lms/batches/${name}#students`
}
window.open(url, '_blank')
}
const statusOptions = computed(() => { const statusOptions = computed(() => {
return [ return [
{ {

View File

@@ -190,7 +190,7 @@ const evaluationCourses = computed(() => {
const canScheduleEvals = computed(() => { const canScheduleEvals = computed(() => {
return ( return (
upcoming_evals.data?.length != evaluationCourses.length && upcoming_evals.data?.length != evaluationCourses.value?.length &&
!props.forHome && !props.forHome &&
!endDateHasPassed.value !endDateHasPassed.value
) )

View File

@@ -130,7 +130,6 @@
import { import {
Breadcrumbs, Breadcrumbs,
Button, Button,
call,
createListResource, createListResource,
Dropdown, Dropdown,
FormControl, FormControl,
@@ -185,24 +184,27 @@ const batches = createListResource({
cache: ['batches', user.data?.name], cache: ['batches', user.data?.name],
pageLength: pageLength.value, pageLength: pageLength.value,
start: start.value, start: start.value,
onSuccess(data) {
let allCategories = data.map((batch) => batch.category)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
},
}) })
const setCategories = (data) => {
let allCategories = data.map((batch) => batch.category)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
}
const updateBatches = () => { const updateBatches = () => {
updateFilters() updateFilters()
batches.update({ batches.update({
filters: filters.value, filters: filters.value,
orderBy: orderBy.value, orderBy: orderBy.value,
}) })
batches.reload() batches.reload().then((data) => {
setCategories(data)
})
} }
const updateFilters = () => { const updateFilters = () => {

View File

@@ -3,7 +3,7 @@
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5" class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<router-link :to="{ name: 'Batches', query: { certification: true } }"> <router-link :to="{ name: 'Courses', query: { certification: true } }">
<Button> <Button>
<template #prefix> <template #prefix>
<GraduationCap class="h-4 w-4 stroke-1.5" /> <GraduationCap class="h-4 w-4 stroke-1.5" />

View File

@@ -168,9 +168,6 @@ const courses = createListResource({
cache: ['courses', user.data?.name], cache: ['courses', user.data?.name],
pageLength: pageLength.value, pageLength: pageLength.value,
start: start.value, start: start.value,
onSuccess(data) {
setCategories(data)
},
}) })
const setCategories = (data) => { const setCategories = (data) => {
@@ -205,7 +202,7 @@ const identifyUserPersona = async () => {
const getCourseCount = () => { const getCourseCount = () => {
if (!user.data) return if (!user.data) return
if (!user.data.is_moderator) return
call('frappe.client.get_count', { call('frappe.client.get_count', {
doctype: 'LMS Course', doctype: 'LMS Course',
}).then((data) => { }).then((data) => {
@@ -219,7 +216,9 @@ const updateCourses = () => {
courses.update({ courses.update({
filters: filters.value, filters: filters.value,
}) })
courses.reload() courses.reload().then((data) => {
setCategories(data)
})
} }
const updateFilters = () => { const updateFilters = () => {

View File

@@ -86,7 +86,7 @@
<Uploader <Uploader
v-model="job.company_logo" v-model="job.company_logo"
:label="__('Company Logo')" :label="__('Company Logo')"
:required="false" :required="true"
/> />
</div> </div>
</div> </div>

View File

@@ -26,7 +26,7 @@
class="flex space-x-2 px-2 py-4" class="flex space-x-2 px-2 py-4"
:class="{ :class="{
'cursor-pointer': log.link, 'cursor-pointer': log.link,
'items-center': !showDetails(log) && !isMention(log), 'items-center': !showDetails(log) && !isMentionOrComment(log),
}" }"
@click="navigateToPage(log)" @click="navigateToPage(log)"
> >
@@ -56,9 +56,9 @@
</div> </div>
</div> </div>
<div <div
v-if="isMention(log)" v-if="isMentionOrComment(log)"
v-html="log.email_content" v-html="log.email_content"
class="bg-surface-gray-2 rounded-md px-3 py-2" class="bg-surface-gray-2 rounded-md px-3 py-2 line-clamp-3 overflow-hidden"
></div> ></div>
<div <div
v-else-if="showDetails(log)" v-else-if="showDetails(log)"
@@ -260,7 +260,7 @@ const navigateToPage = (log) => {
} }
} }
const isMention = (log) => { const isMentionOrComment = (log) => {
if (log.type == 'Mention') { if (log.type == 'Mention') {
return true return true
} }

View File

@@ -21,5 +21,5 @@ export default {
}, },
}, },
}, },
plugins: [], plugins: [require('@tailwindcss/line-clamp')],
} }

57
lms/auth.py Normal file
View File

@@ -0,0 +1,57 @@
import frappe
ALLOWED_PATHS = [
"/api/method/ping",
"/api/method/login",
"/api/method/logout",
"/api/method/frappe.core.doctype.communication.email.mark_email_as_seen",
"/api/method/frappe.realtime.get_user_info",
"/api/method/frappe.realtime.can_subscribe_doc",
"/api/method/frappe.realtime.can_subscribe_doctype",
"/api/method/frappe.realtime.has_permission",
"/api/method/frappe.integrations.oauth2.authorize",
"/api/method/frappe.integrations.oauth2.approve",
"/api/method/frappe.integrations.oauth2.get_token",
"/api/method/frappe.integrations.oauth2.openid_profile",
"/api/method/frappe.website.doctype.web_page_view.web_page_view.make_view_log",
"/api/method/upload_file",
"/api/method/frappe.search.web_search",
"/api/method/frappe.email.queue.unsubscribe",
"/api/method/frappe.website.doctype.web_form.web_form.accept",
"/api/method/frappe.core.doctype.user.user.test_password_strength",
"/api/method/frappe.core.doctype.user.user.update_password",
"/api/method/frappe.utils.telemetry.pulse.client.is_enabled",
"/api/method/frappe.client.get_value",
"/api/method/frappe.client.get_count",
"/api/method/frappe.client.get",
"/api/method/frappe.client.insert",
"/api/method/frappe.client.set_value",
"/api/method/frappe.client.delete",
"/api/method/frappe.client.get_list",
"/api/method/frappe.client.rename_doc",
"/api/method/frappe.onboarding.get_onboarding_status",
"/api/method/frappe.utils.print_format.download_pdf",
"/api/method/frappe.desk.search.search_link",
"/api/method/frappe.core.doctype.communication.email.make",
]
def authenticate():
if frappe.form_dict.cmd:
path = f"/api/method/{frappe.form_dict.cmd}"
else:
path = frappe.request.path
user_type = frappe.db.get_value("User", frappe.session.user, "user_type")
if user_type == "System User":
return
if not path.startswith("/api/"):
return
print("path", path)
if path.startswith("/lms") or path.startswith("/api/method/lms."):
return
if path in ALLOWED_PATHS:
return
frappe.throw(f"Access not allowed for this URL: {path}", frappe.PermissionError)

View File

@@ -262,3 +262,4 @@ add_to_apps_screen = [
] ]
sqlite_search = ["lms.sqlite.LearningSearch"] sqlite_search = ["lms.sqlite.LearningSearch"]
auth_hooks = ["lms.auth.authenticate"]

View File

@@ -5,7 +5,7 @@
from frappe.tests import UnitTestCase from frappe.tests import UnitTestCase
from frappe.utils import add_days, format_time, getdate from frappe.utils import add_days, format_time, getdate
from lms.lms.doctype.course_evaluator.course_evaluator import get_schedule from lms.lms.doctype.course_evaluator.course_evaluator import get_schedule, get_schedule_range_end_date
from lms.lms.test_utils import TestUtils from lms.lms.test_utils import TestUtils
@@ -37,7 +37,7 @@ class TestCourseEvaluator(UnitTestCase):
def test_schedule_dates(self): def test_schedule_dates(self):
schedule = get_schedule(self.batch.courses[0].course, self.batch.name) schedule = get_schedule(self.batch.courses[0].course, self.batch.name)
first_date = self.calculated_first_date_of_schedule() first_date = self.calculated_first_date_of_schedule()
last_date = self.calculated_last_date_of_schedule(first_date) last_date = self.calculated_last_date_of_schedule()
self.assertEqual(getdate(schedule[0].get("date")), first_date) self.assertEqual(getdate(schedule[0].get("date")), first_date)
self.assertEqual(getdate(schedule[-1].get("date")), last_date) self.assertEqual(getdate(schedule[-1].get("date")), last_date)
@@ -51,17 +51,10 @@ class TestCourseEvaluator(UnitTestCase):
first_date = add_days(today, offset_wednesday) first_date = add_days(today, offset_wednesday)
return first_date return first_date
def calculated_last_date_of_schedule(self, first_date): def calculated_last_date_of_schedule(self):
last_day = add_days(getdate(), 56) last_day = getdate(get_schedule_range_end_date(getdate(), self.batch.name))
offset_monday = (0 - last_day.weekday() + 7) % 7 # 0 for Monday while last_day.weekday() not in (0, 2):
offset_wednesday = (2 - last_day.weekday() + 7) % 7 # 2 for Wednesday last_day = add_days(last_day, -1)
if offset_monday < offset_wednesday and offset_monday <= 4:
last_day = add_days(last_day, offset_monday)
elif offset_wednesday <= 4:
last_day = add_days(last_day, offset_wednesday)
else:
last_day = add_days(last_day, min(offset_monday, offset_wednesday) + 7)
return last_day return last_day

View File

@@ -421,7 +421,7 @@ def get_batch_details_for_notification(topic):
users = [] users = []
batch_title = frappe.db.get_value("LMS Batch", topic.reference_docname, "title") batch_title = frappe.db.get_value("LMS Batch", topic.reference_docname, "title")
subject = _("New comment in batch {0}").format(batch_title) subject = _("New comment in batch {0}").format(batch_title)
link = f"/lms/batches/{topic.reference_docname}" link = f"/lms/batches/{topic.reference_docname}#discussions"
instructors = frappe.db.get_all( instructors = frappe.db.get_all(
"Course Instructor", "Course Instructor",
{"parenttype": "LMS Batch", "parent": topic.reference_docname}, {"parenttype": "LMS Batch", "parent": topic.reference_docname},
@@ -590,32 +590,24 @@ def get_chart_date_range(from_date, to_date):
def get_chart_filters(doctype, chart, datefield, from_date, to_date): def get_chart_filters(doctype, chart, datefield, from_date, to_date):
version = get_frappe_version() version = get_frappe_version()
if version.startswith("16."): if version.startswith("15.") or version.startswith("14."):
filters = [([chart.document_type, "docstatus", "<", 2])]
filters = filters + json.loads(chart.filters_json)
filters.append([doctype, datefield, ">=", from_date])
filters.append([doctype, datefield, "<=", to_date])
else:
filters = [([chart.document_type, "docstatus", "<", 2, False])] filters = [([chart.document_type, "docstatus", "<", 2, False])]
filters = filters + json.loads(chart.filters_json) filters = filters + json.loads(chart.filters_json)
filters.append([doctype, datefield, ">=", from_date, False]) filters.append([doctype, datefield, ">=", from_date, False])
filters.append([doctype, datefield, "<=", to_date, False]) filters.append([doctype, datefield, "<=", to_date, False])
else:
filters = [([chart.document_type, "docstatus", "<", 2])]
filters = filters + json.loads(chart.filters_json)
filters.append([doctype, datefield, ">=", from_date])
filters.append([doctype, datefield, "<=", to_date])
return filters return filters
def get_chart_details(doctype, datefield, value_field, chart, from_date, to_date): def get_chart_details(doctype, datefield, value_field, chart, from_date, to_date):
filters = get_chart_filters(doctype, chart, datefield, from_date, to_date) filters = get_chart_filters(doctype, chart, datefield, from_date, to_date)
version = get_frappe_version() version = get_frappe_version()
if version.startswith("16."): if version.startswith("15.") or version.startswith("14."):
return frappe.db.get_all(
doctype,
fields=[datefield, {"SUM": value_field}, {"COUNT": "*"}],
filters=filters,
group_by=datefield,
order_by=datefield,
as_list=True,
)
else:
return frappe.db.get_all( return frappe.db.get_all(
doctype, doctype,
fields=[f"{datefield} as _unit", f"SUM({value_field})", "COUNT(*)"], fields=[f"{datefield} as _unit", f"SUM({value_field})", "COUNT(*)"],
@@ -624,6 +616,15 @@ def get_chart_details(doctype, datefield, value_field, chart, from_date, to_date
order_by="_unit asc", order_by="_unit asc",
as_list=True, as_list=True,
) )
else:
return frappe.db.get_all(
doctype,
fields=[datefield, {"SUM": value_field}, {"COUNT": "*"}],
filters=filters,
group_by=datefield,
order_by=datefield,
as_list=True,
)
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)

36
lms/test_auth.py Normal file
View File

@@ -0,0 +1,36 @@
import frappe
from frappe.tests import UnitTestCase
from frappe.tests.test_api import FrappeAPITestCase
from lms.auth import authenticate
from lms.lms.test_utils import TestUtils
class TestAuth(FrappeAPITestCase):
def setUp(self):
self.normal_user = TestUtils.create_user(
self, "normal-user@example.com", "Normal", "User", ["LMS Student"]
)
def test_allowed_path(self):
site_url = frappe.utils.get_site_url(frappe.local.site)
headers = {"Authorization": "Bearer set_test_example_user"}
url = site_url + "/api/method/lms.lms.utils.get_courses"
response = self.get(
url,
headers=headers,
)
self.assertNotEqual(response.json.get("exc_type"), "PermissionError")
def test_not_allowed_path(self):
site_url = frappe.utils.get_site_url(frappe.local.site)
headers = {"Authorization": "Bearer set_test_example_user"}
url = site_url + "/api/method/frappe.auth.get_logged_user"
response = self.get(
url,
headers=headers,
)
self.assertEqual(response.json.get("exc_type"), "PermissionError")
def tearDown(self):
frappe.delete_doc("User", self.normal_user.name)