Merge pull request #2313 from frappe/main-hotfix

chore: merge 'main-hotfix' into 'main'
This commit is contained in:
Raizaaa
2026-04-18 17:23:04 +03:00
committed by GitHub
28 changed files with 713 additions and 368 deletions

View File

@@ -38,9 +38,9 @@ jobs:
- name: Set Branch
run: |
export APPS_JSON='[{"url": "https://github.com/frappe/lms","branch": "main"}]'
export APPS_JSON='[{"url": "https://github.com/frappe/payments","branch": "version-15"},{"url": "https://github.com/frappe/lms","branch": "main"}]'
echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV
echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV
echo "FRAPPE_BRANCH=version-16" >> $GITHUB_ENV
- name: Set Image Tag
run: |
@@ -61,4 +61,4 @@ jobs:
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
build-args: |
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"

View File

@@ -24,6 +24,7 @@ bench set-redis-socketio-host redis://redis:6379
sed -i '/redis/d' ./Procfile
sed -i '/watch/d' ./Procfile
bench get-app payments
bench get-app lms
bench new-site lms.localhost \
@@ -32,6 +33,7 @@ bench new-site lms.localhost \
--admin-password admin \
--no-mariadb-socket
bench --site lms.localhost install-app payments
bench --site lms.localhost install-app lms
bench --site lms.localhost set-config developer_mode 1
bench --site lms.localhost clear-cache

View File

@@ -17,7 +17,7 @@
</div>
</div>
<div class="text-ink-gray-9 font-semibold mb-5">
{{ __('Assignment Question') }}
{{ __('Assignment') }}: {{ assignment.data.title }}
</div>
<div
v-html="assignment.data.question"
@@ -300,7 +300,7 @@ const submitAssignment = () => {
}
}
const addNewSubmission = () => {
const prepareSubmissionDoc = () => {
let doc = {
doctype: 'LMS Assignment Submission',
assignment: props.assignmentID,
@@ -311,24 +311,31 @@ const addNewSubmission = () => {
} else {
doc.assignment_attachment = attachment.value
}
return doc
}
const addNewSubmission = () => {
let doc = prepareSubmissionDoc()
if (!doc.assignment_attachment && !doc.answer) {
toast.error(
__('Please provide an answer or upload a file before submitting.')
)
return
}
call('frappe.client.insert', {
doc: doc,
})
.then((data) => {
toast.success(__('Assignment submitted successfully'))
if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: props.assignmentID,
submissionName: data.name,
},
query: { fromLesson: router.currentRoute.value.query.fromLesson },
})
} else {
markLessonProgress()
router.go()
}
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: props.assignmentID,
submissionName: data.name,
},
query: { fromLesson: router.currentRoute.value.query.fromLesson },
})
markLessonProgress()
isDirty.value = false
submissionResource.name = data.name
submissionResource.reload()
@@ -372,15 +379,17 @@ const saveSubmission = (file) => {
}
const markLessonProgress = () => {
if (router.currentRoute.value.name == 'Lesson') {
let courseName = router.currentRoute.value.params.courseName
let chapterNumber = router.currentRoute.value.params.chapterNumber
let lessonNumber = router.currentRoute.value.params.lessonNumber
let pathname = window.location.pathname.split('/')
if (!pathname.includes('courses'))
pathname = window.parent.location.pathname.split('/')
if (pathname[2] != 'courses') return
let lessonIndex = pathname.pop().split('-')
if (lessonIndex.length == 2) {
call('lms.lms.api.mark_lesson_progress', {
course: courseName,
chapter_number: chapterNumber,
lesson_number: lessonNumber,
course: pathname[3],
chapter_number: lessonIndex[0],
lesson_number: lessonIndex[1],
})
}
}

View File

@@ -1,7 +1,7 @@
<template>
<div
v-if="course.title"
class="flex flex-col h-full rounded-md overflow-auto text-ink-gray-9"
class="flex flex-col h-full rounded-md overflow-auto text-ink-gray-9 bg-surface-cards"
style="min-height: 350px"
>
<div
@@ -10,7 +10,7 @@
course.image
? { backgroundImage: `url('${encodeURI(course.image)}')` }
: {
backgroundImage: getGradientColor(),
backgroundImage: gradientColor,
backgroundBlendMode: 'screen',
}
"
@@ -137,6 +137,8 @@ import { Award, BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { Tooltip } from 'frappe-ui'
import { formatAmount } from '@/utils'
import { theme } from '@/utils/theme'
import { computed, watch } from 'vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import ProgressBar from '@/components/ProgressBar.vue'
@@ -151,12 +153,12 @@ const props = defineProps({
},
})
const getGradientColor = () => {
let theme = localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
const gradientColor = computed(() => {
let themeMode = theme.value === 'dark' ? 'darkMode' : 'lightMode'
let color = props.course.card_gradient?.toLowerCase() || 'blue'
let colorMap = colors[theme][color]
let colorMap = colors[themeMode][color]
return `linear-gradient(to top right, black, ${colorMap[400]})`
}
})
</script>
<style>
.course-card-pills {

View File

@@ -57,7 +57,7 @@
import { getSidebarLinks } from '@/utils'
import { useRouter } from 'vue-router'
import { call } from 'frappe-ui'
import { watch, ref, onMounted } from 'vue'
import { ref, watch } from 'vue'
import { sessionStore } from '@/stores/session'
import { useSettings } from '@/stores/settings'
import { usersStore } from '@/stores/user'
@@ -68,26 +68,13 @@ let { isLoggedIn } = sessionStore()
const { sidebarSettings } = useSettings()
const router = useRouter()
let { userResource } = usersStore()
const sidebarLinks = ref(getSidebarLinks())
const sidebarLinks = ref([])
const otherLinks = ref([])
const showMenu = ref(false)
const menu = ref(null)
const isModerator = ref(false)
const isInstructor = ref(false)
onMounted(() => {
sidebarSettings.reload(
{},
{
onSuccess(data) {
destructureSidebarLinks()
filterLinksToShow(data)
addOtherLinks()
},
}
)
})
const handleOutsideClick = (e) => {
if (menu.value && !menu.value.contains(e.target)) {
showMenu.value = false
@@ -126,65 +113,57 @@ const filterLinksToShow = (data) => {
const addOtherLinks = () => {
if (user) {
otherLinks.value.push({
label: 'Notifications',
icon: 'Bell',
to: 'Notifications',
})
otherLinks.value.push({
label: 'Profile',
icon: 'UserRound',
})
otherLinks.value.push({
label: 'Log out',
icon: 'LogOut',
})
addLink('Notifications', 'Bell', 'Notifications')
addLink('Profile', 'UserRound')
addLink('Log out', 'LogOut')
} else {
otherLinks.value.push({
label: 'Log in',
icon: 'LogIn',
})
addLink('Log in', 'LogIn')
}
}
watch(userResource, () => {
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addPrograms()
if (isModerator.value || isInstructor.value) {
addProgrammingExercises()
addQuizzes()
addAssignments()
const addLink = (label, icon, to = '') => {
if (otherLinks.value.some((link) => link.label === label)) return
otherLinks.value.push({
label: label,
icon: icon,
to: to,
})
}
const updateSidebarLinks = () => {
sidebarLinks.value = getSidebarLinks(true)
destructureSidebarLinks()
sidebarSettings.reload(
{},
{
onSuccess: async (data) => {
filterLinksToShow(data)
await addPrograms()
if (isModerator.value || isInstructor.value) {
addQuizzes()
addAssignments()
addProgrammingExercises()
}
addOtherLinks()
},
}
}
})
)
}
const addQuizzes = () => {
otherLinks.value.push({
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
})
addLink('Quizzes', 'CircleHelp', 'Quizzes')
}
const addAssignments = () => {
otherLinks.value.push({
label: 'Assignments',
icon: 'Pencil',
to: 'Assignments',
})
addLink('Assignments', 'Pencil', 'Assignments')
}
const addProgrammingExercises = () => {
otherLinks.value.push({
label: 'Programming Exercises',
icon: 'Code',
to: 'ProgrammingExercises',
})
addLink('Programming Exercises', 'Code', 'ProgrammingExercises')
}
const addPrograms = async () => {
if (sidebarLinks.value.some((link) => link.label === 'Programs')) return
let canAddProgram = await checkIfCanAddProgram()
if (!canAddProgram) return
let activeFor = ['Programs', 'ProgramDetail']
@@ -198,7 +177,21 @@ const addPrograms = async () => {
})
}
watch(
userResource,
async () => {
await userResource.promise
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
}
updateSidebarLinks()
},
{ immediate: true }
)
const checkIfCanAddProgram = async () => {
if (!userResource.data) return false
if (isModerator.value || isInstructor.value) {
return true
}

View File

@@ -224,6 +224,7 @@
</div>
<div class="flex items-center justify-between mt-8">
<Checkbox
v-if="!quiz.data.show_answers"
:label="__('Mark for review')"
:model-value="reviewQuestions.includes(activeQuestion) ? 1 : 0"
@change="markForReview($event, activeQuestion)"
@@ -278,6 +279,7 @@
!showAnswers.length &&
questionDetails.data.type != 'Open Ended'
"
class="ml-auto"
@click="checkAnswer()"
>
<span>
@@ -289,12 +291,18 @@
activeQuestion != questions.length && quiz.data.show_answers
"
@click="nextQuestion()"
class="ml-auto"
>
<span>
{{ __('Next') }}
</span>
</Button>
<Button variant="solid" v-else @click="handleSubmitClick()">
<Button
variant="solid"
v-else
@click="handleSubmitClick()"
class="ml-auto"
>
<span>
{{ __('Submit') }}
</span>
@@ -891,10 +899,14 @@ const markLessonProgress = () => {
}
const handleSubmitClick = () => {
if (attemptedQuestions.value.length) {
switchQuestion(activeQuestion.value)
if (!quiz.data.show_answers) {
if (attemptedQuestions.value.length) {
switchQuestion(activeQuestion.value)
}
showSubmissionConfirmation.value = true
} else {
submitQuiz()
}
showSubmissionConfirmation.value = true
}
const paginationWindow = computed(() => {

View File

@@ -32,16 +32,14 @@
</div>
<div v-if="transactionData" class="overflow-y-auto">
<div class="grid grid-cols-3 gap-5">
<Switch
size="sm"
<FormControl
:label="__('Payment Received')"
:description="__('Mark the payment as received.')"
type="checkbox"
v-model="transactionData.payment_received"
/>
<Switch
size="sm"
<FormControl
:label="__('Payment For Certificate')"
:description="__('This payment is for a certificate.')"
type="checkbox"
v-model="transactionData.payment_for_certificate"
/>
<FormControl

View File

@@ -27,17 +27,15 @@
doctype="User"
:placeholder="__('Filter by Member')"
/>
<Switch
size="sm"
:label="__('Payment Received')"
:description="__('Mark the payment as received.')"
<FormControl
v-model="paymentReceived"
type="checkbox"
:label="__('Payment Received')"
/>
<Switch
size="sm"
:label="__('Payment For Certificate')"
:description="__('This payment is for a certificate.')"
<FormControl
v-model="paymentForCertificate"
type="checkbox"
:label="__('Payment for Certificate')"
/>
</div>
@@ -47,12 +45,12 @@
:rows="transactions.data"
row-key="name"
:options="{
showTooltip: false,
selectable: false,
onRowClick: (row: { [key: string]: any }) => {
openForm(row)
},
}"
showTooltip: false,
selectable: false,
onRowClick: (row: { [key: string]: any }) => {
openForm(row)
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"

View File

@@ -68,6 +68,7 @@ import { sessionStore } from '@/stores/session'
import { call, Dropdown, toast } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { convertToTitleCase } from '@/utils'
import { applyTheme, toggleTheme, theme } from '@/utils/theme'
import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings'
import { markRaw, watch, ref, onMounted, computed } from 'vue'
@@ -94,7 +95,6 @@ let { userResource } = usersStore()
const settingsStore = useSettings()
let { isLoggedIn } = sessionStore()
const showSettingsModal = ref(false)
const theme = ref('light')
const frappeCloudBaseEndpoint = 'https://frappecloud.com'
const $dialog = createDialog
@@ -106,9 +106,8 @@ const props = defineProps({
})
onMounted(() => {
theme.value = localStorage.getItem('theme') || 'light'
if (['light', 'dark'].includes(theme.value)) {
document.documentElement.setAttribute('data-theme', theme.value)
applyTheme(theme.value)
}
})
@@ -119,13 +118,6 @@ watch(
}
)
const toggleTheme = () => {
const currentTheme = document.documentElement.getAttribute('data-theme')
theme.value = currentTheme === 'dark' ? 'light' : 'dark'
document.documentElement.setAttribute('data-theme', theme.value)
localStorage.setItem('theme', theme.value)
}
const userDropdownOptions = computed(() => {
return [
{

View File

@@ -20,18 +20,15 @@
</Button>
</header>
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
<div class="py-5 mx-5">
<div class="flex items-center justify-between mb-5">
<div v-if="assignmentCount" class="text-lg font-semibold text-ink-gray-9">
{{ __('{0} Assignments').format(assignmentCount) }}
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('{0} Assignments').format(assignments.data?.length) }}
</div>
<div
v-if="assignments.data?.length || assignmentCount > 0"
class="grid grid-cols-2 gap-5"
>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="titleFilter"
:placeholder="__('Search by title')"
:placeholder="__('Search by Title')"
/>
<FormControl
v-model="typeFilter"
@@ -48,23 +45,75 @@
row-key="name"
:options="{
showTooltip: false,
selectable: false,
selectable: true,
onRowClick: (row) => {
if (readOnlyMode) return
assignmentID = row.name
showAssignmentForm = true
},
}"
class="h-[79vh] border-b"
>
<ListHeader
class="mb-2 grid items-center rounded bg-surface-white border-b rounded-none p-2"
>
<ListHeaderItem :item="item" v-for="item in assignmentColumns">
<template #prefix="{ item }">
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow
v-for="row in assignments.data"
:row="row"
class="hover:bg-surface-gray-2"
>
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'show_answers'">
<FormControl
type="checkbox"
v-model="row[column.key]"
:disabled="true"
/>
</div>
<div
v-else-if="column.key == 'modified'"
class="text-sm text-ink-gray-5"
>
{{ row[column.key] }}
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner class="bottom-50">
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="deleteAssignment(selections, unselectAll)"
>
<FeatherIcon name="trash-2" class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
<EmptyState v-else type="Assignments" />
<div
v-if="assignments.data && assignments.hasNextPage"
class="flex justify-center my-5"
>
<Button @click="assignments.next()">
<div class="flex items-center justify-end space-x-3 mt-3">
<Button v-if="assignments.hasNextPage" @click="assignments.next()">
{{ __('Load More') }}
</Button>
<div v-if="assignments.hasNextPage" class="h-8 border-l"></div>
<div class="text-ink-gray-5">
{{ assignments.data?.length }} {{ __('of') }}
{{ totalAssignments.data }}
</div>
</div>
</div>
<AssignmentForm
@@ -79,8 +128,17 @@ import {
Button,
call,
createListResource,
createResource,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
FeatherIcon,
toast,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue'
@@ -96,7 +154,6 @@ const titleFilter = ref('')
const typeFilter = ref('')
const showAssignmentForm = ref(false)
const assignmentID = ref('new')
const assignmentCount = ref(0)
const { brand } = sessionStore()
const router = useRouter()
const route = useRoute()
@@ -110,7 +167,6 @@ onMounted(() => {
assignmentID.value = 'new'
showAssignmentForm.value = true
}
getAssignmentCount()
titleFilter.value = router.currentRoute.value.query.title
typeFilter.value = router.currentRoute.value.query.type
})
@@ -123,6 +179,9 @@ watch([titleFilter, typeFilter], () => {
},
})
reloadAssignments()
totalAssignments.update({
filters: assignmentFilter.value,
})
})
const reloadAssignments = () => {
@@ -137,7 +196,7 @@ const assignmentFilter = computed(() => {
if (titleFilter.value) {
filters.title = ['like', `%${titleFilter.value}%`]
}
if (typeFilter.value) {
if (typeFilter.value && typeFilter.value.trim() !== '') {
filters.type = typeFilter.value
}
return filters
@@ -145,51 +204,60 @@ const assignmentFilter = computed(() => {
const assignments = createListResource({
doctype: 'LMS Assignment',
fields: ['name', 'title', 'type', 'creation', 'question', 'course'],
fields: ['name', 'title', 'type', 'modified', 'question', 'course'],
orderBy: 'modified desc',
cache: ['assignments'],
transform(data) {
return data.map((row) => {
return {
...row,
creation: dayjs(row.creation).fromNow(),
modified: dayjs(row.modified).format('DD MMM YYYY'),
}
})
},
})
const totalAssignments = createResource({
url: 'frappe.client.get_count',
params: {
doctype: 'LMS Assignment',
filters: assignmentFilter.value,
},
auto: true,
cache: ['assignments_count', user.data?.name],
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
})
const assignmentColumns = computed(() => {
return [
{
label: __('Title'),
key: 'title',
width: 2,
width: 1,
icon: 'file-text',
},
{
label: __('Type'),
key: 'type',
width: 1,
align: 'left',
icon: 'tag',
},
{
label: __('Created'),
key: 'creation',
label: __('Modified'),
key: 'modified',
width: 1,
align: 'right',
icon: 'clock',
},
]
})
const getAssignmentCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Assignment',
}).then((data) => {
assignmentCount.value = data
})
}
const assignmentTypes = computed(() => {
let types = ['', 'Document', 'Image', 'PDF', 'URL', 'Text']
let types = [' ', 'Document', 'Image', 'PDF', 'URL', 'Text']
return types.map((type) => {
return {
label: __(type),
@@ -198,6 +266,14 @@ const assignmentTypes = computed(() => {
})
})
const deleteAssignment = (selections, unselectAll) => {
Array.from(selections).forEach(async (assignmentName) => {
await assignments.delete.submit(assignmentName)
})
unselectAll()
toast.success(__('Assignments deleted successfully'))
}
const breadcrumbs = computed(() => [
{
label: __('Assignments'),

View File

@@ -199,8 +199,15 @@
}}
</div>
</div>
<Button variant="solid" size="md" @click="generatePaymentLink()">
{{ __('Proceed to Payment') }}
<Button
variant="solid"
size="md"
class="ms-auto"
@click="generatePaymentLink()"
>
{{
isZeroAmount ? __('Enroll for Free') : __('Proceed to Payment')
}}
</Button>
</div>
</div>
@@ -326,16 +333,10 @@ const paymentLink = createResource({
let data = {
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
docname: props.name,
title: orderSummary.data.title,
amount: orderSummary.data.original_amount,
discount_amount: orderSummary.data.discount_amount || 0,
gst_amount: orderSummary.data.gst_applied || 0,
currency: orderSummary.data.currency,
address: billingDetails,
redirect_to: redirectTo.value,
payment_for_certificate: props.type == 'certificate',
coupon_code: appliedCoupon.value,
coupon: orderSummary.data.coupon,
country: billingDetails.country,
}
return data
},
@@ -458,14 +459,8 @@ const changeCurrency = (country) => {
orderSummary.reload()
}
const redirectTo = computed(() => {
if (props.type == 'course') {
return getLmsRoute(`courses/${props.name}`)
} else if (props.type == 'batch') {
return getLmsRoute(`batches/${props.name}`)
} else if (props.type == 'certificate') {
return getLmsRoute(`courses/${props.name}/certification`)
}
const isZeroAmount = computed(() => {
return orderSummary.data && parseFloat(orderSummary.data.total_amount) <= 0
})
watch(billingDetails, () => {

View File

@@ -41,16 +41,16 @@
</div>
</div>
<div class="flex items-center space-x-4">
<Switch
size="sm"
<FormControl
v-model="openToWork"
:label="__('Open to Work')"
type="checkbox"
@change="updateParticipants()"
/>
<Switch
size="sm"
<FormControl
v-model="hiring"
:label="__('Hiring')"
type="checkbox"
@change="updateParticipants()"
/>
</div>
@@ -129,7 +129,6 @@ import {
createListResource,
FormControl,
Select,
Switch,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'

View File

@@ -156,20 +156,17 @@ const isAdmin = computed(() => {
const exportCourse = async () => {
try {
const response = await fetch(
'/api/method/lms.lms.api.export_course_as_zip',
'/api/method/lms.lms.api.export_course_as_zip?course_name=' +
course.data.name,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
course_name: course.data.name,
}),
method: 'GET',
credentials: 'include',
}
)
if (!response.ok) {
const errorText = await response.text()
console.error('Error response:', errorText)
throw new Error('Download failed')
}

View File

@@ -59,7 +59,7 @@
class="h-[120px] flex items-center justify-center bg-surface-gray-1 border border-dashed border-outline-gray-3 rounded-md"
>
<div
class="w-fit bg-surface-white border rounded-md p-2 flex items-center justify-between items-center space-x-4"
class="w-fit bg-surface-white border rounded-md p-2 flex items-center justify-between items-center space-x-4 mx-5"
>
<div class="space-y-2">
<div class="font-medium leading-5 text-ink-gray-9">

View File

@@ -132,6 +132,7 @@ import ChildTable from '@/components/Controls/ChildTable.vue'
const show = defineModel()
const exercises = defineModel<ProgrammingExercises>('exercises')
const totalExercises = defineModel<number>('totalExercises')
const isDirty = ref(false)
const originalTestCaseCount = ref(0)
@@ -150,7 +151,6 @@ const languageOptions = [
const props = withDefaults(
defineProps<{
exerciseID: string
getExerciseCount: () => Promise<number>
}>(),
{
exerciseID: 'new',
@@ -257,7 +257,7 @@ const createNewExercise = (close: () => void) => {
close()
isDirty.value = false
exercises.value?.reload()
props.getExerciseCount()
totalExercises.value.reload()
toast.success(__('Programming Exercise created successfully'))
},
onError(err: any) {

View File

@@ -37,7 +37,7 @@
<div class="p-5">
<div class="flex items-center justify-between mb-5">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('{0} Exercises').format(exerciseCount) }}
{{ __('{0} Exercises').format(exercises.data?.length) }}
</div>
<div class="grid grid-cols-2 gap-5">
<FormControl
@@ -69,9 +69,10 @@
showForm = true
},
}"
class="h-[79vh] border-b"
>
<ListHeader
class="mb-2 grid items-center rounded bg-surface-gray-2 p-2"
class="mb-2 grid items-center rounded bg-surface-white border-b rounded-none p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
@@ -115,20 +116,21 @@
</ListView>
</div>
<EmptyState v-else type="Programming Exercises" />
<div
v-if="exercises.data && exercises.hasNextPage"
class="flex justify-center my-5"
>
<Button @click="exercises.next()">
<div class="flex items-center justify-end space-x-3 mt-3">
<Button v-if="exercises.hasNextPage" @click="exercises.next()">
{{ __('Load More') }}
</Button>
<div v-if="exercises.hasNextPage" class="h-8 border-l"></div>
<div class="text-ink-gray-5">
{{ exercises.data?.length }} {{ __('of') }} {{ totalExercises.data }}
</div>
</div>
</div>
<ProgrammingExerciseForm
v-model="showForm"
v-model:exercises="exercises"
:exerciseID="exerciseID"
:getExerciseCount="getExerciseCount"
v-model:totalExercises="totalExercises"
/>
</template>
<script setup lang="ts">
@@ -137,6 +139,7 @@ import {
Breadcrumbs,
Button,
call,
createResource,
createListResource,
dayjs,
FeatherIcon,
@@ -156,7 +159,6 @@ import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import ProgrammingExerciseForm from '@/pages/ProgrammingExercises/ProgrammingExerciseForm.vue'
const exerciseCount = ref<number>(0)
const readOnlyMode = window.read_only_mode
const { brand } = sessionStore()
const showForm = ref<boolean>(false)
@@ -170,7 +172,6 @@ const { $dialog } = app?.appContext.config.globalProperties
onMounted(() => {
validatePermissions()
getExerciseCount()
})
const validatePermissions = () => {
@@ -185,19 +186,6 @@ const validatePermissions = () => {
}
}
const getExerciseCount = (filters: any = {}) => {
call('frappe.client.get_count', {
doctype: 'LMS Programming Exercise',
filters: filters,
})
.then((count: number) => {
exerciseCount.value = count
})
.catch((error: any) => {
console.error('Error fetching exercise count:', error)
})
}
const exercises = createListResource({
doctype: 'LMS Programming Exercise',
cache: ['programmingExercises'],
@@ -212,7 +200,9 @@ const updateList = () => {
filters: filters,
})
exercises.reload()
getExerciseCount(filters)
totalExercises.update({
filters: filters,
})
}
const getFilters = () => {
@@ -266,6 +256,20 @@ const deleteExercises = (selections: Set<string>, unselectAll: () => void) => {
unselectAll()
}
const totalExercises = createResource({
url: 'frappe.client.get_count',
params: {
doctype: 'LMS Programming Exercise',
filters: getFilters(),
},
auto: true,
cache: ['programming_exercises_count', user.data?.name],
onError(err: any) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
})
const languages = [
{ label: ' ', value: ' ' },
{ label: 'Python', value: 'Python' },
@@ -277,13 +281,13 @@ const columns = computed(() => {
{
label: __('Title'),
key: 'title',
width: 3,
width: 1,
icon: 'file-text',
},
{
label: __('Language'),
key: 'language',
width: 2,
width: 1,
align: 'left',
icon: 'code',
},
@@ -292,6 +296,7 @@ const columns = computed(() => {
key: 'modified',
width: 1,
icon: 'clock',
align: 'right',
},
]
})

View File

@@ -205,7 +205,7 @@ const quizzes = createListResource({
return data.map((quiz) => {
return {
...quiz,
modified: dayjs(quiz.modified).fromNow(true),
modified: dayjs(quiz.modified).format('DD MMM YYYY'),
}
})
},
@@ -303,7 +303,7 @@ const quizColumns = computed(() => {
label: __('Updated On'),
key: 'modified',
width: 1,
align: 'center',
align: 'right',
icon: 'clock',
},
]

View File

@@ -403,8 +403,8 @@ export function getUserTimezone() {
}
}
export function getSidebarLinks() {
let links = getSidebarItems()
export function getSidebarLinks(forMobile = false) {
let links = getSidebarItems(forMobile)
links.forEach((link) => {
link.items = link.items.filter((item) => {
@@ -419,7 +419,7 @@ export function getSidebarLinks() {
return links
}
const getSidebarItems = () => {
const getSidebarItems = (forMobile = false) => {
const { userResource } = usersStore()
const { settings } = useSettings()
@@ -441,7 +441,7 @@ const getSidebarItems = () => {
icon: 'Search',
to: 'Search',
condition: () => {
return userResource?.data
return !forMobile && userResource?.data
},
},
{
@@ -449,7 +449,7 @@ const getSidebarItems = () => {
icon: 'Bell',
to: 'Notifications',
condition: () => {
return userResource?.data
return !forMobile && userResource?.data
},
},
],
@@ -476,7 +476,7 @@ const getSidebarItems = () => {
activeFor: ['Programs', 'ProgramDetail'],
await: true,
condition: () => {
return checkIfCanAddProgram()
return checkIfCanAddProgram(forMobile)
},
},
{
@@ -514,7 +514,8 @@ const getSidebarItems = () => {
: settings.data?.contact_us_email,
condition: () => {
return (
(settings?.data?.contact_us_email &&
(!forMobile &&
settings?.data?.contact_us_email &&
userResource?.data) ||
settings?.data?.contact_us_url
)
@@ -531,7 +532,7 @@ const getSidebarItems = () => {
icon: 'CircleHelp',
to: 'Quizzes',
condition: () => {
return isAdmin()
return !forMobile && isAdmin()
},
activeFor: [
'Quizzes',
@@ -546,7 +547,7 @@ const getSidebarItems = () => {
icon: 'Pencil',
to: 'Assignments',
condition: () => {
return isAdmin()
return !forMobile && isAdmin()
},
activeFor: [
'Assignments',
@@ -559,7 +560,7 @@ const getSidebarItems = () => {
icon: 'Code',
to: 'ProgrammingExercises',
condition: () => {
return isAdmin()
return !forMobile && isAdmin()
},
activeFor: [
'ProgrammingExercises',
@@ -581,10 +582,11 @@ const isAdmin = () => {
)
}
const checkIfCanAddProgram = () => {
const checkIfCanAddProgram = (forMobile = false) => {
const { userResource } = usersStore()
const { programs } = useSettings()
if (!userResource.data) return false
if (forMobile) return false
if (userResource?.data?.is_moderator || userResource?.data?.is_instructor) {
return true
}

View File

@@ -0,0 +1,16 @@
import { ref } from 'vue'
const theme = ref<'light' | 'dark'>(localStorage.getItem('theme') as 'light' | 'dark' || 'light')
const toggleTheme = () => {
const newTheme: 'light' | 'dark' = theme.value === 'dark' ? 'light' : 'dark'
applyTheme(newTheme)
}
const applyTheme = (value: 'light' | 'dark') => {
document.documentElement.setAttribute('data-theme', value)
localStorage.setItem('theme', value)
theme.value = value
}
export { applyTheme, toggleTheme, theme }

View File

@@ -456,6 +456,7 @@ def create_evaluator(zip_file):
return
if not frappe.db.exists("User", evaluator_data["evaluator"]):
evaluator_data["email"] = evaluator_data["evaluator"]
create_user(evaluator_data)
if not frappe.db.exists("Course Evaluator", evaluator_data["name"]):

View File

@@ -20,11 +20,12 @@
"section_break_ydgh",
"column_break_oqqy",
"status",
"question",
"column_break_tbnv",
"comments",
"section_break_rqal",
"question",
"column_break_esgd",
"course",
"column_break_esgd",
"lesson"
],
"fields": [
@@ -145,13 +146,17 @@
{
"fieldname": "section_break_ydgh",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_tbnv",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2026-02-05 11:38:03.792865",
"modified": "2026-04-06 18:24:11.837953",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Assignment Submission",

View File

@@ -35,10 +35,10 @@ class LMSCertificate(Document):
custom_template = frappe.db.get_single_value("LMS Settings", "certification_template")
args = {
"student_name": self.member_name,
"member_name": self.member_name,
"course_name": self.course,
"course_title": frappe.db.get_value("LMS Course", self.course, "title"),
"certificate_name": self.name,
"name": self.name,
"template": self.template,
}

View File

@@ -1,5 +1,11 @@
import frappe
from lms.lms.utils import (
complete_enrollment,
get_lms_route,
get_order_summary,
)
def get_payment_gateway():
return frappe.db.get_single_value("LMS Settings", "payment_gateway")
@@ -21,22 +27,25 @@ def validate_currency(payment_gateway, currency):
def get_payment_link(
doctype: str,
docname: str,
title: str,
amount: float,
discount_amount: float,
gst_amount: float,
currency: str,
address: dict,
redirect_to: str,
payment_for_certificate: int,
coupon_code: str = None,
coupon: str = None,
coupon_code: str | None = None,
country: str | None = None,
):
payment_gateway = get_payment_gateway()
address = frappe._dict(address)
original_amount = amount
amount -= discount_amount
redirect_to = get_redirect_url(doctype, docname, payment_for_certificate)
details = frappe._dict(get_order_summary(doctype, docname, coupon=coupon_code, country=country))
title = details.title
currency = details.currency
original_amount = details.original_amount
discount_amount = details.get("discount_amount", 0)
gst_amount = details.get("gst_applied", 0)
amount = original_amount - discount_amount
amount_with_gst = get_amount_with_gst(amount, gst_amount)
coupon = details.get("coupon")
total_amount = amount_with_gst if amount_with_gst else amount
payment = record_payment(
address,
@@ -51,10 +60,16 @@ def get_payment_link(
coupon_code,
coupon,
)
if total_amount <= 0:
frappe.db.set_value("LMS Payment", payment.name, "payment_received", 1)
complete_enrollment(payment.name, doctype, docname)
return redirect_to
controller = get_controller(payment_gateway)
payment_details = {
"amount": amount_with_gst if amount_with_gst else amount,
"amount": total_amount,
"title": f"Payment for {doctype} {title} {docname}",
"description": f"{address.billing_name}'s payment for {title}",
"reference_doctype": doctype,
@@ -99,8 +114,8 @@ def record_payment(
amount_with_gst: float = 0,
discount_amount: float = 0,
payment_for_certificate: int = 0,
coupon_code: str = None,
coupon: str = None,
coupon_code: str | None = None,
coupon: str | None = None,
):
address = frappe._dict(address)
address_name = save_address(address)
@@ -138,6 +153,15 @@ def record_payment(
return payment_doc
def get_redirect_url(doctype: str, docname: str, payment_for_certificate: int) -> str:
if int(payment_for_certificate):
return get_lms_route(f"courses/{docname}/certification")
elif doctype == "LMS Course":
return get_lms_route(f"courses/{docname}")
else:
return get_lms_route(f"batches/{docname}")
def save_address(address: dict) -> str:
filters = {"email_id": frappe.session.user}
exists = frappe.db.exists("Address", filters)

View File

@@ -1907,17 +1907,21 @@ def update_payment_record(doctype: str, docname: str):
if len(request):
data = request[0].data
data = frappe._dict(json.loads(data))
payment_doc = get_payment_doc(data.payment)
update_payment_details(data)
update_coupon_redemption(payment_doc)
complete_enrollment(data.payment, doctype, docname)
if payment_doc.payment_for_certificate:
update_certificate_purchase(docname, data.payment)
elif doctype == "LMS Course":
enroll_in_course(docname, data.payment)
else:
enroll_in_batch(docname, data.payment)
def complete_enrollment(payment_name: str, doctype: str, docname: str):
payment_doc = get_payment_doc(payment_name)
update_coupon_redemption(payment_doc)
if payment_doc.payment_for_certificate:
update_certificate_purchase(docname, payment_name)
elif doctype == "LMS Course":
enroll_in_course(docname, payment_name)
else:
enroll_in_batch(docname, payment_name)
def get_integration_requests(doctype: str, docname: str):

View File

@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Learning VERSION\n"
"Report-Msgid-Bugs-To: jannat@frappe.io\n"
"POT-Creation-Date: 2026-03-27 16:17+0000\n"
"PO-Revision-Date: 2026-03-27 16:17+0000\n"
"POT-Creation-Date: 2026-04-03 16:11+0000\n"
"PO-Revision-Date: 2026-04-03 16:11+0000\n"
"Last-Translator: jannat@frappe.io\n"
"Language-Team: jannat@frappe.io\n"
"MIME-Version: 1.0\n"
@@ -334,7 +334,7 @@ msgstr ""
msgid "All Batches"
msgstr ""
#: frontend/src/pages/Courses/Courses.vue:54 lms/lms/widgets/BreadCrumb.html:3
#: frontend/src/pages/Courses/Courses.vue:36 lms/lms/widgets/BreadCrumb.html:3
msgid "All Courses"
msgstr ""
@@ -926,11 +926,11 @@ msgstr ""
msgid "Batch end date cannot be before the batch start date"
msgstr ""
#: lms/lms/api.py:194
#: lms/lms/api.py:193
msgid "Batch has already started."
msgstr ""
#: lms/lms/api.py:189
#: lms/lms/api.py:188
msgid "Batch is sold out."
msgstr ""
@@ -1040,7 +1040,7 @@ msgstr ""
msgid "Cancelled"
msgstr ""
#: lms/lms/api.py:2325
#: lms/lms/api.py:2335
msgid "Cannot search for roles: {0}"
msgstr ""
@@ -1063,7 +1063,7 @@ msgstr ""
#: frontend/src/pages/Batches/components/NewBatchModal.vue:51
#: frontend/src/pages/CertifiedParticipants.vue:38
#: frontend/src/pages/Courses/CourseForm.vue:23
#: frontend/src/pages/Courses/Courses.vue:74
#: frontend/src/pages/Courses/Courses.vue:56
#: frontend/src/pages/Courses/NewCourseModal.vue:21
#: lms/lms/doctype/lms_batch/lms_batch.json
#: lms/lms/doctype/lms_category/lms_category.json
@@ -1079,7 +1079,7 @@ msgstr ""
msgid "Category added successfully"
msgstr ""
#: frontend/src/utils/index.js:835
#: frontend/src/utils/index.js:837
msgid "Category created successfully"
msgstr ""
@@ -1140,7 +1140,7 @@ msgstr ""
#: frontend/src/pages/Batches/Batches.vue:85
#: frontend/src/pages/Courses/CourseCertification.vue:10
#: frontend/src/pages/Courses/CourseCertification.vue:135
#: frontend/src/pages/Courses/Courses.vue:84 lms/fixtures/custom_field.json
#: frontend/src/pages/Courses/Courses.vue:66 lms/fixtures/custom_field.json
#: lms/lms/doctype/certification/certification.json
#: lms/lms/doctype/lms_batch/lms_batch.json
#: lms/lms/doctype/lms_enrollment/lms_enrollment.json
@@ -1667,6 +1667,10 @@ msgstr ""
msgid "Correct Answer"
msgstr ""
#: lms/lms/course_import_export.py:196
msgid "Could not create the course ZIP file. Please try again later. Error: {0}"
msgstr ""
#. Label of the country (Link) field in DocType 'User'
#. Label of the country (Link) field in DocType 'Job Opportunity'
#. Label of the country (Link) field in DocType 'Payment Country'
@@ -1941,9 +1945,9 @@ msgstr ""
#: frontend/src/pages/Batches/components/BatchDashboard.vue:20
#: frontend/src/pages/Batches/components/BatchOverlay.vue:45
#: frontend/src/pages/Courses/CourseCertification.vue:127
#: frontend/src/pages/Courses/CourseDetail.vue:143
#: frontend/src/pages/Courses/Courses.vue:356
#: frontend/src/pages/Courses/Courses.vue:363 frontend/src/pages/Lesson.vue:564
#: frontend/src/pages/Courses/CourseDetail.vue:227
#: frontend/src/pages/Courses/Courses.vue:374
#: frontend/src/pages/Courses/Courses.vue:381 frontend/src/pages/Lesson.vue:564
#: frontend/src/pages/LessonForm.vue:475
#: frontend/src/pages/Programs/ProgramForm.vue:49
#: frontend/src/pages/Programs/Programs.vue:35
@@ -1984,7 +1988,7 @@ msgstr ""
#: frontend/src/components/Modals/ChapterModal.vue:9
#: frontend/src/pages/Assignments.vue:19
#: frontend/src/pages/Batches/Batches.vue:33
#: frontend/src/pages/Courses/Courses.vue:36
#: frontend/src/pages/Courses/Courses.vue:18
#: frontend/src/pages/ProgrammingExercises/ProgrammingExercises.vue:33
#: frontend/src/pages/Quizzes.vue:10
msgid "Create"
@@ -2061,7 +2065,7 @@ msgid "Create your first quiz"
msgstr ""
#: frontend/src/pages/Assignments.vue:175
#: frontend/src/pages/Courses/Courses.vue:346
#: frontend/src/pages/Courses/Courses.vue:335
msgid "Created"
msgstr ""
@@ -2129,7 +2133,7 @@ msgid "Cyan"
msgstr ""
#. Label of the show_dashboard (Check) field in DocType 'LMS Settings'
#: frontend/src/pages/Courses/CourseDetail.vue:102
#: frontend/src/pages/Courses/CourseDetail.vue:116
#: lms/lms/doctype/lms_settings/lms_settings.json
msgid "Dashboard"
msgstr ""
@@ -2204,6 +2208,7 @@ msgstr ""
#: frontend/src/components/Settings/Badges.vue:171
#: frontend/src/components/Settings/Coupons/CouponList.vue:133
#: frontend/src/pages/Batches/BatchForm.vue:507
#: frontend/src/pages/Courses/CourseDetail.vue:216
#: frontend/src/pages/Courses/CourseForm.vue:544
#: frontend/src/pages/ProgrammingExercises/ProgrammingExerciseForm.vue:71
#: frontend/src/pages/ProgrammingExercises/ProgrammingExercises.vue:240
@@ -2256,7 +2261,7 @@ msgstr ""
msgid "Deleting this lesson will permanently remove it from the course. This action cannot be undone. Are you sure you want to continue?"
msgstr ""
#: lms/lms/api.py:757
#: lms/lms/api.py:756
msgid "Deletion not allowed for {0}"
msgstr ""
@@ -2307,6 +2312,10 @@ msgstr ""
msgid "Details cannot be empty."
msgstr ""
#: frontend/src/pages/Courses/CourseImportModal.vue:33
msgid "Device"
msgstr ""
#. Label of the disable_pwa (Check) field in DocType 'LMS Settings'
#: lms/lms/doctype/lms_settings/lms_settings.json
msgid "Disable PWA"
@@ -2392,6 +2401,10 @@ msgstr ""
msgid "Dont miss this opportunity to enhance your skills. Click below to complete your enrollment"
msgstr ""
#: frontend/src/pages/Courses/CourseImportModal.vue:28
msgid "Drag and drop a ZIP file, or upload from your"
msgstr ""
#. Label of the dream_companies (Data) field in DocType 'User'
#: lms/fixtures/custom_field.json
msgid "Dream Companies"
@@ -2666,7 +2679,7 @@ msgstr ""
#: frontend/src/pages/Batches/Batches.vue:328
#: frontend/src/pages/Batches/components/AdminBatchDashboard.vue:5
#: frontend/src/pages/Courses/CourseDashboard.vue:5
#: frontend/src/pages/Courses/Courses.vue:349
#: frontend/src/pages/Courses/Courses.vue:338
#: frontend/src/pages/Programs/StudentPrograms.vue:96
msgid "Enrolled"
msgstr ""
@@ -2781,6 +2794,10 @@ msgstr ""
msgid "Error deleting payment gateways"
msgstr ""
#: lms/lms/course_import_export.py:298
msgid "Error downloading file"
msgstr ""
#: frontend/src/components/Settings/GoogleMeetAccountModal.vue:194
msgid "Error updating Google Meet Account"
msgstr ""
@@ -2875,7 +2892,7 @@ msgstr ""
msgid "Evaluator added successfully"
msgstr ""
#: frontend/src/components/Settings/Evaluators.vue:190
#: frontend/src/components/Settings/Evaluators.vue:192
msgid "Evaluator deleted successfully"
msgstr ""
@@ -2970,6 +2987,10 @@ msgstr ""
msgid "Explore More"
msgstr ""
#: frontend/src/pages/Courses/CourseDetail.vue:209
msgid "Export"
msgstr ""
#. Option for the 'Status' (Select) field in DocType 'LMS Assignment
#. Submission'
#. Option for the 'Status' (Select) field in DocType 'LMS Certificate
@@ -3021,7 +3042,7 @@ msgstr ""
msgid "Failed to update badge assignment: "
msgstr ""
#: frontend/src/utils/index.js:899
#: frontend/src/utils/index.js:901
msgid "Failed to update meta tags {0}"
msgstr ""
@@ -3059,6 +3080,15 @@ msgstr ""
msgid "File Type"
msgstr ""
#: lms/lms/course_import_export.py:286
msgid "File not found"
msgstr ""
#: frontend/src/pages/Courses/CourseImportModal.vue:150
#: frontend/src/pages/Courses/CourseImportModal.vue:166
msgid "File upload failed. Please try again."
msgstr ""
#: frontend/src/components/AssessmentPlugin.vue:55
msgid "Filter assignments by course"
msgstr ""
@@ -3543,6 +3573,7 @@ msgid "Image: Corrupted Data Stream"
msgstr ""
#: frontend/src/components/Sidebar/Configuration.vue:36
#: frontend/src/pages/Courses/CourseImportModal.vue:83
msgid "Import"
msgstr ""
@@ -3550,8 +3581,16 @@ msgstr ""
msgid "Import Batch"
msgstr ""
#: frontend/src/pages/Courses/Courses.vue:20
msgid "Import Course"
#: frontend/src/pages/Courses/CourseImportModal.vue:5
msgid "Import Course from ZIP"
msgstr ""
#: frontend/src/pages/Courses/Courses.vue:353
msgid "Import via Data Import Tool"
msgstr ""
#: frontend/src/pages/Courses/Courses.vue:363
msgid "Import via ZIP"
msgstr ""
#. Option for the 'Status' (Select) field in DocType 'LMS Certificate
@@ -3680,10 +3719,26 @@ msgstr ""
msgid "Invalid Quiz ID in content"
msgstr ""
#: lms/lms/api.py:760
#: lms/lms/course_import_export.py:770
msgid "Invalid ZIP file"
msgstr ""
#: lms/lms/course_import_export.py:339
msgid "Invalid course ZIP: Missing course.json"
msgstr ""
#: lms/lms/api.py:1010
msgid "Invalid course or chapter name"
msgstr ""
#: lms/lms/api.py:759
msgid "Invalid document name"
msgstr ""
#: lms/lms/api.py:1017
msgid "Invalid file path in package"
msgstr ""
#: frontend/src/components/Sidebar/AppSidebar.vue:515
msgid "Invite your team and students"
msgstr ""
@@ -4266,7 +4321,7 @@ msgstr ""
msgid "LinkedIn ID"
msgstr ""
#: frontend/src/pages/Courses/Courses.vue:329
#: frontend/src/pages/Courses/Courses.vue:318
msgid "Live"
msgstr ""
@@ -4291,7 +4346,7 @@ msgstr ""
#: frontend/src/pages/Batches/components/AdminBatchDashboard.vue:119
#: frontend/src/pages/CertifiedParticipants.vue:118
#: frontend/src/pages/Courses/CourseDashboard.vue:119
#: frontend/src/pages/Courses/Courses.vue:107
#: frontend/src/pages/Courses/Courses.vue:89
#: frontend/src/pages/JobApplications.vue:101
#: frontend/src/pages/ProgrammingExercises/ProgrammingExerciseSubmissions.vue:133
#: frontend/src/pages/ProgrammingExercises/ProgrammingExercises.vue:123
@@ -4397,7 +4452,7 @@ msgstr ""
msgid "Mark"
msgstr ""
#: frontend/src/pages/Notifications.vue:12
#: frontend/src/pages/Notifications.vue:14
msgid "Mark all as read"
msgstr ""
@@ -4732,7 +4787,7 @@ msgstr ""
msgid "Meta Tags"
msgstr ""
#: lms/lms/api.py:1542
#: lms/lms/api.py:1552
msgid "Meta tags should be a list."
msgstr ""
@@ -4792,11 +4847,11 @@ msgstr ""
msgid "Modified By"
msgstr ""
#: lms/lms/api.py:171
#: lms/lms/api.py:170
msgid "Module Name is incorrect or does not exist."
msgstr ""
#: lms/lms/api.py:167
#: lms/lms/api.py:166
msgid "Module is incorrect."
msgstr ""
@@ -4861,7 +4916,7 @@ msgstr ""
#: frontend/src/components/Settings/Members.vue:17
#: frontend/src/components/Settings/PaymentGateways.vue:16
#: frontend/src/components/Settings/ZoomSettings.vue:17
#: frontend/src/pages/Courses/Courses.vue:333
#: frontend/src/pages/Courses/Courses.vue:322
#: frontend/src/pages/Programs/Programs.vue:10
#: lms/lms/doctype/lms_badge/lms_badge.json
msgid "New"
@@ -4869,7 +4924,7 @@ msgstr ""
#: frontend/src/pages/Batches/Batches.vue:10
#: frontend/src/pages/Batches/components/NewBatchModal.vue:5
#: frontend/src/pages/Notifications.vue:90 lms/www/_lms.py:154
#: frontend/src/pages/Notifications.vue:95 lms/www/_lms.py:154
msgid "New Batch"
msgstr ""
@@ -4877,9 +4932,9 @@ msgstr ""
msgid "New Coupon"
msgstr ""
#: frontend/src/pages/Courses/Courses.vue:13
#: frontend/src/pages/Courses/Courses.vue:346
#: frontend/src/pages/Courses/NewCourseModal.vue:5
#: frontend/src/pages/Notifications.vue:89 lms/www/_lms.py:98
#: frontend/src/pages/Notifications.vue:94 lms/www/_lms.py:98
msgid "New Course"
msgstr ""
@@ -4924,11 +4979,11 @@ msgstr ""
msgid "New Zoom Account"
msgstr ""
#: lms/lms/utils.py:427
#: lms/lms/utils.py:471
msgid "New comment in batch {0}"
msgstr ""
#: lms/lms/utils.py:418
#: lms/lms/utils.py:462
msgid "New reply on the topic {0} in course {1}"
msgstr ""
@@ -5045,6 +5100,10 @@ msgstr ""
msgid "No quizzes added yet."
msgstr ""
#: frontend/src/pages/Notifications.vue:153
msgid "No read notifications"
msgstr ""
#: frontend/src/components/Controls/Autocomplete.vue:136
#: frontend/src/components/Controls/MultiSelect.vue:77
#: frontend/src/pages/Search/Search.vue:47
@@ -5067,6 +5126,10 @@ msgstr ""
msgid "No submissions"
msgstr ""
#: frontend/src/pages/Notifications.vue:152
msgid "No unread notifications"
msgstr ""
#: frontend/src/components/EmptyState.vue:5 lms/templates/course_list.html:13
msgid "No {0}"
msgstr ""
@@ -5122,10 +5185,6 @@ msgstr ""
msgid "Notes"
msgstr ""
#: frontend/src/pages/Notifications.vue:143
msgid "Nothing to see here."
msgstr ""
#. Label of the notification_sent (Check) field in DocType 'LMS Batch'
#. Label of the notification_sent (Check) field in DocType 'LMS Course'
#: lms/lms/doctype/lms_batch/lms_batch.json
@@ -5140,6 +5199,10 @@ msgstr ""
msgid "Notifications"
msgstr ""
#: frontend/src/pages/Notifications.vue:160
msgid "Notifications you have read will appear here."
msgstr ""
#: lms/lms/widgets/NoPreviewModal.html:30
msgid "Notify me when available"
msgstr ""
@@ -5197,6 +5260,10 @@ msgstr ""
msgid "Only PDF files are allowed."
msgstr ""
#: frontend/src/utils/index.js:661
msgid "Only ZIP files are allowed."
msgstr ""
#: frontend/src/utils/index.js:658
msgid "Only document file of type .doc or .docx are allowed."
msgstr ""
@@ -5213,7 +5280,7 @@ msgstr ""
msgid "Only show batches that offer a certificate"
msgstr ""
#: frontend/src/pages/Courses/Courses.vue:80
#: frontend/src/pages/Courses/Courses.vue:62
msgid "Only show courses that offer a certificate"
msgstr ""
@@ -5221,7 +5288,7 @@ msgstr ""
msgid "Only zip files are allowed"
msgstr ""
#: frontend/src/utils/index.js:664
#: frontend/src/utils/index.js:666
msgid "Only {0} file is allowed."
msgstr ""
@@ -5339,7 +5406,7 @@ msgstr ""
msgid "Oversee all users, content, and system settings"
msgstr ""
#: frontend/src/pages/Courses/CourseDetail.vue:97
#: frontend/src/pages/Courses/CourseDetail.vue:111
msgid "Overview"
msgstr ""
@@ -5594,11 +5661,11 @@ msgstr ""
msgid "Please add <a href='{0}'>{1}</a> for <a href='{2}'>{3}</a> to send calendar invites for evaluations."
msgstr ""
#: lms/lms/user.py:75
#: lms/lms/user.py:77
msgid "Please ask your administrator to verify your sign-up"
msgstr ""
#: lms/lms/user.py:73
#: lms/lms/user.py:75
msgid "Please check your email for verification"
msgstr ""
@@ -5678,15 +5745,15 @@ msgstr ""
msgid "Please login to access the quiz."
msgstr ""
#: lms/lms/api.py:163
#: lms/lms/api.py:162
msgid "Please login to continue with payment."
msgstr ""
#: lms/lms/utils.py:2038
#: lms/lms/utils.py:2081
msgid "Please login to view program details."
msgstr ""
#: lms/lms/utils.py:2003
#: lms/lms/utils.py:2046
msgid "Please login to view programs."
msgstr ""
@@ -6455,7 +6522,7 @@ msgstr ""
msgid "SEO"
msgstr ""
#: frontend/src/utils/index.js:681
#: frontend/src/utils/index.js:683
msgid "SVG contains potentially unsafe content."
msgstr ""
@@ -6486,7 +6553,7 @@ msgstr ""
#: frontend/src/components/Settings/Transactions/TransactionDetails.vue:29
#: frontend/src/pages/Batches/BatchDetail.vue:17
#: frontend/src/pages/Batches/components/NewBatchModal.vue:107
#: frontend/src/pages/Courses/CourseDetail.vue:17
#: frontend/src/pages/Courses/CourseDetail.vue:21
#: frontend/src/pages/Courses/NewCourseModal.vue:69
#: frontend/src/pages/JobForm.vue:12 frontend/src/pages/LessonForm.vue:14
#: frontend/src/pages/ProgrammingExercises/ProgrammingExerciseForm.vue:107
@@ -6533,7 +6600,7 @@ msgstr ""
#: frontend/src/components/Settings/Evaluators.vue:57
#: frontend/src/components/Settings/Members.vue:25
#: frontend/src/pages/Courses/Courses.vue:64 frontend/src/pages/Jobs.vue:59
#: frontend/src/pages/Courses/Courses.vue:46 frontend/src/pages/Jobs.vue:59
#: frontend/src/pages/Search/Search.vue:5
#: frontend/src/pages/Search/Search.vue:250
msgid "Search"
@@ -6716,7 +6783,7 @@ msgstr ""
#: frontend/src/components/Settings/Settings.vue:9
#: frontend/src/components/Sidebar/AppSidebar.vue:640
#: frontend/src/pages/Courses/CourseDetail.vue:107
#: frontend/src/pages/Courses/CourseDetail.vue:121
#: frontend/src/pages/ProfileRoles.vue:4
#: frontend/src/pages/ProgrammingExercises/ProgrammingExerciseSubmission.vue:19
#: frontend/src/pages/QuizForm.vue:86
@@ -7101,7 +7168,7 @@ msgstr ""
msgid "Sunday"
msgstr ""
#: lms/lms/api.py:1033
#: lms/lms/api.py:1043
msgid "Suspicious pattern found in {0}: {1}"
msgstr ""
@@ -7189,7 +7256,7 @@ msgstr ""
msgid "Template"
msgstr ""
#: lms/lms/user.py:40
#: lms/lms/user.py:42
msgid "Temporarily Disabled"
msgstr ""
@@ -7237,7 +7304,7 @@ msgstr ""
msgid "The Google Meet account does not have a Google Calendar configured. Please set up a Google Calendar first."
msgstr ""
#: lms/lms/utils.py:2274
#: lms/lms/utils.py:2317
msgid "The batch does not exist."
msgstr ""
@@ -7245,7 +7312,7 @@ msgstr ""
msgid "The batch you have enrolled for is starting tomorrow. Please be prepared and be on time for the session."
msgstr ""
#: lms/lms/utils.py:1757
#: lms/lms/utils.py:1800
msgid "The coupon code '{0}' is invalid."
msgstr ""
@@ -7269,7 +7336,7 @@ msgstr ""
msgid "The last day to schedule your evaluations is "
msgstr ""
#: lms/lms/utils.py:2258
#: lms/lms/utils.py:2301
msgid "The lesson does not exist."
msgstr ""
@@ -7285,7 +7352,7 @@ msgstr ""
msgid "The slot is already booked by another participant."
msgstr ""
#: lms/lms/utils.py:1455 lms/lms/utils.py:1955
#: lms/lms/utils.py:1498 lms/lms/utils.py:1998
msgid "The specified batch does not exist."
msgstr ""
@@ -7332,6 +7399,10 @@ msgstr ""
msgid "This badge has not been assigned to any students yet"
msgstr ""
#: lms/lms/doctype/lms_enrollment/lms_enrollment.py:56
msgid "This batch is not associated with this course."
msgstr ""
#. Label of the expire (Check) field in DocType 'Certification'
#: lms/lms/doctype/certification/certification.json
msgid "This certificate does no expire"
@@ -7343,15 +7414,15 @@ msgstr ""
msgid "This class has ended"
msgstr ""
#: lms/lms/utils.py:1786
#: lms/lms/utils.py:1829
msgid "This coupon has expired."
msgstr ""
#: lms/lms/utils.py:1789
#: lms/lms/utils.py:1832
msgid "This coupon has reached its maximum usage limit."
msgstr ""
#: lms/lms/utils.py:1798
#: lms/lms/utils.py:1841
msgid "This coupon is not applicable to this {0}."
msgstr ""
@@ -7359,7 +7430,7 @@ msgstr ""
msgid "This course has:"
msgstr ""
#: lms/lms/utils.py:1717
#: lms/lms/utils.py:1760
msgid "This course is free."
msgstr ""
@@ -7566,11 +7637,11 @@ msgstr ""
msgid "To Date"
msgstr ""
#: lms/lms/utils.py:1731
#: lms/lms/utils.py:1774
msgid "To join this batch, please contact the Administrator."
msgstr ""
#: lms/lms/user.py:41
#: lms/lms/user.py:43
msgid "Too many users signed up recently, so the registration is disabled. Please try back in an hour"
msgstr ""
@@ -7690,7 +7761,7 @@ msgstr ""
msgid "Unable to add member"
msgstr ""
#: frontend/src/utils/index.js:840
#: frontend/src/utils/index.js:842
msgid "Unable to create category"
msgstr ""
@@ -7718,10 +7789,14 @@ msgid "Under Review"
msgstr ""
#: frontend/src/pages/Batches/Batches.vue:326
#: frontend/src/pages/Courses/Courses.vue:347
#: frontend/src/pages/Courses/Courses.vue:336
msgid "Unpublished"
msgstr ""
#: lms/lms/course_import_export.py:773
msgid "Unsafe file path detected"
msgstr ""
#: frontend/src/components/Modals/EditCoverImage.vue:60
#: frontend/src/components/UnsplashImageBrowser.vue:54
msgid "Unsplash"
@@ -7741,7 +7816,7 @@ msgstr ""
#. Label of the upcoming (Check) field in DocType 'LMS Course'
#: frontend/src/pages/Batches/Batches.vue:324
#: frontend/src/pages/Courses/CourseForm.vue:129
#: frontend/src/pages/Courses/Courses.vue:337
#: frontend/src/pages/Courses/Courses.vue:326
#: lms/lms/doctype/lms_certificate_request/lms_certificate_request.json
#: lms/lms/doctype/lms_course/lms_course.json
msgid "Upcoming"
@@ -7849,7 +7924,7 @@ msgstr ""
msgid "User Skill"
msgstr ""
#: lms/lms/api.py:1783
#: lms/lms/api.py:1793
msgid "User does not have permission to access this user's profile details."
msgstr ""
@@ -8053,39 +8128,39 @@ msgstr ""
msgid "You are already certified for this course. Click on the card below to open your certificate."
msgstr ""
#: lms/lms/api.py:183
#: lms/lms/api.py:182
msgid "You are already enrolled for this batch."
msgstr ""
#: lms/lms/api.py:177
#: lms/lms/api.py:176
msgid "You are already enrolled for this course."
msgstr ""
#: lms/lms/utils.py:1251
#: lms/lms/utils.py:1294
msgid "You are not authorized to view the assessments of this batch."
msgstr ""
#: lms/lms/utils.py:1453
#: lms/lms/utils.py:1496
msgid "You are not authorized to view the chart data of this batch."
msgstr ""
#: lms/lms/utils.py:2045
#: lms/lms/utils.py:2088
msgid "You are not authorized to view the details of this program."
msgstr ""
#: lms/lms/utils.py:1663
#: lms/lms/utils.py:1706
msgid "You are not authorized to view the discussion replies for this topic."
msgstr ""
#: lms/lms/utils.py:1616
#: lms/lms/utils.py:1659
msgid "You are not authorized to view the discussion topics for this item."
msgstr ""
#: lms/lms/utils.py:1217
#: lms/lms/utils.py:1260
msgid "You are not authorized to view the question details."
msgstr ""
#: lms/lms/utils.py:1362
#: lms/lms/utils.py:1405
msgid "You are not authorized to view the students of this batch."
msgstr ""
@@ -8130,11 +8205,11 @@ msgstr ""
msgid "You cannot change the roles in read-only mode."
msgstr ""
#: lms/lms/doctype/lms_enrollment/lms_enrollment.py:59
#: lms/lms/doctype/lms_enrollment/lms_enrollment.py:64
msgid "You cannot enroll in an unpublished course."
msgstr ""
#: lms/lms/utils.py:2106
#: lms/lms/utils.py:2149
msgid "You cannot enroll in an unpublished program."
msgstr ""
@@ -8150,35 +8225,35 @@ msgstr ""
msgid "You cannot schedule evaluations for past slots."
msgstr ""
#: lms/lms/utils.py:2286
#: lms/lms/utils.py:2329
msgid "You do not have access to this batch."
msgstr ""
#: lms/lms/utils.py:2269
#: lms/lms/utils.py:2312
msgid "You do not have access to this course."
msgstr ""
#: lms/lms/api.py:855 lms/lms/doctype/lms_batch/lms_batch.py:365
#: lms/lms/api.py:854 lms/lms/doctype/lms_batch/lms_batch.py:365
msgid "You do not have permission to access announcements for this batch."
msgstr ""
#: lms/lms/api.py:2282
#: lms/lms/api.py:2292
msgid "You do not have permission to access badges."
msgstr ""
#: lms/lms/api.py:1145
#: lms/lms/api.py:1155
msgid "You do not have permission to access heatmap data."
msgstr ""
#: lms/lms/api.py:2121
#: lms/lms/api.py:2131
msgid "You do not have permission to access lesson completion stats."
msgstr ""
#: lms/lms/api.py:2161
#: lms/lms/api.py:2171
msgid "You do not have permission to access this course's assessment data."
msgstr ""
#: lms/lms/api.py:1684
#: lms/lms/api.py:1694
msgid "You do not have permission to access this course's progress data."
msgstr ""
@@ -8186,7 +8261,7 @@ msgstr ""
msgid "You do not have permission to access this page."
msgstr ""
#: lms/lms/api.py:1362 lms/lms/api.py:1371
#: lms/lms/api.py:1372 lms/lms/api.py:1381
msgid "You do not have permission to cancel this evaluation."
msgstr ""
@@ -8194,31 +8269,35 @@ msgstr ""
msgid "You do not have permission to create a live class."
msgstr ""
#: lms/lms/api.py:925
#: lms/lms/api.py:924
msgid "You do not have permission to delete this batch."
msgstr ""
#: lms/lms/api.py:1099
#: lms/lms/api.py:1109
msgid "You do not have permission to delete this chapter."
msgstr ""
#: lms/lms/api.py:885
#: lms/lms/api.py:884
msgid "You do not have permission to delete this course."
msgstr ""
#: lms/lms/api.py:514
#: lms/lms/api.py:513
msgid "You do not have permission to delete this lesson."
msgstr ""
#: lms/lms/api.py:597 lms/lms/api.py:973
#: lms/lms/api.py:2372
msgid "You do not have permission to export this course."
msgstr ""
#: lms/lms/api.py:596 lms/lms/api.py:972
msgid "You do not have permission to modify this chapter."
msgstr ""
#: lms/lms/api.py:534
#: lms/lms/api.py:533
msgid "You do not have permission to modify this lesson."
msgstr ""
#: lms/lms/api.py:1431
#: lms/lms/api.py:1441
msgid "You do not have permission to modify this role."
msgstr ""
@@ -8230,11 +8309,11 @@ msgstr ""
msgid "You do not have permission to set up calendar events for this evaluation."
msgstr ""
#: lms/lms/api.py:1567 lms/lms/api.py:1571
#: lms/lms/api.py:1577 lms/lms/api.py:1581
msgid "You do not have permission to update meta tags."
msgstr ""
#: lms/lms/api.py:1608
#: lms/lms/api.py:1618
msgid "You do not have permission to update this submission."
msgstr ""
@@ -8259,7 +8338,7 @@ msgstr ""
msgid "You have already exceeded the maximum number of attempts allowed for this quiz."
msgstr ""
#: lms/lms/api.py:207
#: lms/lms/api.py:206
msgid "You have already purchased the certificate for this course."
msgstr ""
@@ -8323,7 +8402,7 @@ msgstr ""
msgid "You must be enrolled in the course to submit a review"
msgstr ""
#: lms/lms/doctype/lms_enrollment/lms_enrollment.py:73
#: lms/lms/doctype/lms_enrollment/lms_enrollment.py:78
msgid "You need to complete the payment for this course before enrolling."
msgstr ""
@@ -8344,6 +8423,10 @@ msgstr ""
msgid "You will have to get {0}% correct answers in order to pass the quiz."
msgstr ""
#: frontend/src/pages/Notifications.vue:159
msgid "You're all caught up! Check back later for updates."
msgstr ""
#: lms/templates/emails/mentor_request_creation_email.html:4
msgid "You've applied to become a mentor for this course. Your request is currently under review."
msgstr ""
@@ -8666,7 +8749,7 @@ msgstr ""
msgid "{0} Quizzes"
msgstr ""
#: lms/lms/api.py:776 lms/lms/api.py:784
#: lms/lms/api.py:775 lms/lms/api.py:783
msgid "{0} Settings not found"
msgstr ""
@@ -8710,7 +8793,7 @@ msgstr ""
msgid "{0} is your evaluator"
msgstr ""
#: lms/lms/utils.py:520
#: lms/lms/utils.py:564
msgid "{0} mentioned you in a comment"
msgstr ""
@@ -8718,11 +8801,11 @@ msgstr ""
msgid "{0} mentioned you in a comment in your batch."
msgstr ""
#: lms/lms/utils.py:473 lms/lms/utils.py:479
#: lms/lms/utils.py:517 lms/lms/utils.py:523
msgid "{0} mentioned you in a comment in {1}"
msgstr ""
#: lms/lms/api.py:838
#: lms/lms/api.py:837
msgid "{0} not found"
msgstr ""

View File

@@ -16,41 +16,49 @@ class SCORMRenderer(BaseRenderer):
def can_render(self):
return "scorm/" in self.path
def _is_safe_path(self, path):
scorm_root = os.path.realpath(os.path.join(frappe.local.site_path, "public", "scorm"))
resolved = os.path.realpath(path)
return resolved.startswith(scorm_root + os.sep) or resolved == scorm_root
def _serve_file(self, path):
f = open(path, "rb")
response = Response(wrap_file(frappe.local.request.environ, f), direct_passthrough=True)
response.mimetype = mimetypes.guess_type(path)[0]
return response
def render(self):
path = os.path.join(frappe.local.site_path, "public", self.path.lstrip("/"))
if not self._is_safe_path(path):
raise frappe.PermissionError
extension = os.path.splitext(path)[1]
if not extension:
path = f"{path}.html"
# check if path exists and is actually a file and not a folder
if os.path.exists(path) and os.path.isfile(path):
f = open(path, "rb")
response = Response(wrap_file(frappe.local.request.environ, f), direct_passthrough=True)
response.mimetype = mimetypes.guess_type(path)[0]
return response
return self._serve_file(path)
else:
path = path.replace(".html", "")
if os.path.exists(path) and os.path.isdir(path):
index_path = os.path.join(path, "index.html")
if os.path.exists(index_path):
f = open(index_path, "rb")
response = Response(wrap_file(frappe.local.request.environ, f), direct_passthrough=True)
response.mimetype = mimetypes.guess_type(index_path)[0]
return response
return self._serve_file(index_path)
elif not os.path.exists(path):
chapter_folder = "/".join(self.path.split("/")[:3])
chapter_folder_path = os.path.realpath(frappe.get_site_path("public", chapter_folder))
file = path.split("/")[-1]
correct_file_path = None
if not self._is_safe_path(chapter_folder_path):
raise frappe.PermissionError
for root, _dirs, files in os.walk(chapter_folder_path):
if file in files:
correct_file_path = os.path.join(root, file)
break
if correct_file_path:
f = open(correct_file_path, "rb")
response = Response(wrap_file(frappe.local.request.environ, f), direct_passthrough=True)
response.mimetype = mimetypes.guess_type(correct_file_path)[0]
return response
if correct_file_path and self._is_safe_path(correct_file_path):
return self._serve_file(correct_file_path)

View File

@@ -1,5 +1,5 @@
<p>
{{ _("Dear ") }} {{ student_name }},
{{ _("Dear ") }} {{ member_name }},
</p>
<br>
<p>
@@ -10,7 +10,7 @@
{{ _("With this certification, you can now showcase your updated skills and share your achievement with your colleagues and on LinkedIn. To access your certificate, please click on the link provided below. Make sure you are logged in to the portal.") }}
</p>
<br>
<a href="/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name={{certificate_name}}&format={{template | urlencode }}">{{ _("Certificate Link") }}</a>
<a href="/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name={{name}}&format={{template | urlencode }}">{{ _("Certificate Link") }}</a>
<br>
<p>
{{ _("Once again, congratulations on this significant accomplishment.")}}

View File

@@ -0,0 +1,124 @@
{
"app": "lms",
"creation": "2026-04-06 18:02:13.124002",
"docstatus": 0,
"doctype": "Workspace Sidebar",
"header_icon": "book",
"idx": 0,
"items": [
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Courses",
"link_to": "LMS Course",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Enrollments",
"link_to": "LMS Enrollment",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Course Reviews",
"link_to": "LMS Course Review",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Batches",
"link_to": "LMS Batch",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Batch Enrollments",
"link_to": "LMS Batch Enrollment",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Batch Feedback",
"link_to": "LMS Batch Feedback",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Evaluation Requests",
"link_to": "LMS Certificate Request",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Evaluations",
"link_to": "LMS Certificate Evaluation",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
},
{
"child": 0,
"collapsible": 1,
"indent": 0,
"keep_closed": 0,
"label": "Certificates",
"link_to": "LMS Certificate",
"link_type": "DocType",
"open_in_new_tab": 1,
"show_arrow": 0,
"type": "Link"
}
],
"modified": "2026-04-06 18:04:32.990958",
"modified_by": "sayali@frappe.io",
"name": "Learning",
"owner": "sayali@frappe.io",
"standard": 1,
"title": "Learning"
}