Merge remote-tracking branch 'upstream/develop' into feat/rtl

This commit is contained in:
raizasafeel
2026-04-13 10:45:10 +05:30
80 changed files with 8485 additions and 6470 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

@@ -1,2 +1,9 @@
coverage:
status:
project:
default:
target: auto
threshold: 1%
ignore:
- "**/test_helper.py"

View File

@@ -176,7 +176,10 @@ describe("Course Creation", () => {
cy.get("div").contains("Test Course").click();
cy.get("button").contains("Settings").click();
cy.get("header").within(() => {
cy.get("svg.lucide.lucide-trash2-icon").click();
cy.get("svg.lucide.lucide-ellipsis-icon").click();
});
cy.get("div[role=menu]").within(() => {
cy.get("span").contains("Delete").click();
});
cy.get("span").contains("Delete").click();
cy.wait(500);

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

@@ -1,237 +1,231 @@
<!doctype html>
<!DOCTYPE html>
<html lang="{{ boot.lang }}" dir="{{ boot.text_direction }}">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="{{ favicon }}" />
<link rel="manifest" href="/api/method/lms.lms.api.get_pwa_manifest" />
<link rel="apple-touch-icon" href="public/manifest/apple-icon-180.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta
name="theme-color"
content="#FFFFFF"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#0F0F0F"
media="(prefers-color-scheme: dark)"
/>
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="msapplication-navbutton-color" content="#ffffff" />
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2048-2732.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2732-2048.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1668-2388.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2388-1668.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1536-2048.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2048-1536.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1640-2360.jpg"
media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2360-1640.jpg"
media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1668-2224.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2224-1668.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1620-2160.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2160-1620.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1488-2266.jpg"
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2266-1488.jpg"
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1320-2868.jpg"
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2868-1320.jpg"
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1206-2622.jpg"
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2622-1206.jpg"
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1290-2796.jpg"
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2796-1290.jpg"
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1179-2556.jpg"
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2556-1179.jpg"
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1170-2532.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2532-1170.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1284-2778.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2778-1284.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1125-2436.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2436-1125.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1242-2688.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2688-1242.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-828-1792.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1792-828.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1242-2208.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2208-1242.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-750-1334.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1334-750.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-640-1136.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1136-640.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ title }}</title>
<meta name="title" content="{{ meta.title }}" />
<meta name="image" content="{{ meta.image }}" />
<meta name="description" content="{{ meta.description }}" />
<meta name="keywords" content="{{ meta.keywords }}" />
<meta property="og:title" content="{{ meta.title }}" />
<meta property="og:image" content="{{ meta.image }}" />
<meta property="og:description" content="{{ meta.description }}" />
<meta name="twitter:title" content="{{ meta.title }}" />
<meta name="twitter:image" content="{{ meta.image }}" />
<meta name="twitter:description" content="{{ meta.description }}" />
</head>
<body class="sm:overscroll-y-none no-scrollbar">
<div id="app">
<div id="seo-content">
<h1>{{ meta.title }}</h1>
<p>{{ meta.description }}</p>
<a href="{{ meta.link }}">Know More</a>
</div>
</div>
<script>
document.getElementById('seo-content').style.display = 'none'
</script>
<script type="module" src="/src/main.js"></script>
</body>
<head>
<meta charset="UTF-8" />
<link rel="icon" href="{{ favicon }}" />
<link rel="manifest" href="/api/method/lms.lms.api.get_pwa_manifest" />
<link rel="apple-touch-icon" href="public/manifest/apple-icon-180.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0F0F0F" media="(prefers-color-scheme: dark)" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="msapplication-navbutton-color" content="#ffffff" />
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2048-2732.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2732-2048.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1668-2388.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2388-1668.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1536-2048.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2048-1536.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1640-2360.jpg"
media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2360-1640.jpg"
media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1668-2224.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2224-1668.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1620-2160.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2160-1620.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1488-2266.jpg"
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2266-1488.jpg"
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1320-2868.jpg"
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2868-1320.jpg"
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1206-2622.jpg"
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2622-1206.jpg"
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1290-2796.jpg"
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2796-1290.jpg"
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1179-2556.jpg"
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2556-1179.jpg"
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1170-2532.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2532-1170.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1284-2778.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2778-1284.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1125-2436.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2436-1125.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1242-2688.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2688-1242.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-828-1792.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1792-828.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1242-2208.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-2208-1242.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-750-1334.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1334-750.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-640-1136.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="public/manifest/apple-splash-1136-640.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ title | e }}</title>
<meta name="title" content="{{ meta.title | e }}" />
<meta name="image" content="{{ meta.image | e }}" />
<meta name="description" content="{{ meta.description | e }}" />
<meta name="keywords" content="{{ meta.keywords | e }}" />
<meta property="og:title" content="{{ meta.title | e }}" />
<meta property="og:image" content="{{ meta.image | e }}" />
<meta property="og:description" content="{{ meta.description | e }}" />
<meta name="twitter:title" content="{{ meta.title | e }}" />
<meta name="twitter:image" content="{{ meta.image | e }}" />
<meta name="twitter:description" content="{{ meta.description | e }}" />
</head>
<body class="sm:overscroll-y-none no-scrollbar">
<div id="app">
<div id="seo-content">
<h1>{{ meta.title | e }}</h1>
<p>
{{ meta.description | e }}
</p>
<a href="{{ meta.link | e }}">Know More</a>
</div>
</div>
<script>
document.getElementById('seo-content').style.display = 'none';
</script>
<script type="module" src="/src/main.js"></script>
</body>
</html>

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

@@ -247,7 +247,7 @@ const evaluationResource = createResource({
member: props.event.member,
course: props.event.course,
batch_name: props.event.batch_name,
date: props.event.date,
date_value: props.event.date,
start_time: props.event.start_time,
end_time: props.event.end_time,
status: evaluation.status,

View File

@@ -69,7 +69,7 @@
</ol>
</div>
<div v-if="quiz.data.duration" class="flex flex-col gap-x-1 my-4">
<div v-if="quiz.data.duration" class="flex flex-col gap-x-1 my-4 px-2">
<div class="mb-2">
<span class="text-ink-gray-9"> {{ __('Time') }}: </span>
<span class="font-semibold text-ink-gray-9">
@@ -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="ms-auto"
@click="checkAnswer()"
>
<span>
@@ -289,12 +291,18 @@
activeQuestion != questions.length && quiz.data.show_answers
"
@click="nextQuestion()"
class="ms-auto"
>
<span>
{{ __('Next') }}
</span>
</Button>
<Button variant="solid" v-else @click="handleSubmitClick()">
<Button
variant="solid"
v-else
@click="handleSubmitClick()"
class="ms-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

@@ -137,6 +137,7 @@ import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
import AddEvaluatorModal from '@/components/Modals/AddEvaluatorModal.vue'
const search = ref('')
const show = defineModel('show')
const showExistingUser = ref(false)
const showNewEvaluator = ref(false)
const router = useRouter()
@@ -173,6 +174,7 @@ watch(search, () => {
})
const openProfile = (username: string) => {
show.value = false
router.push({
name: 'Profile',
params: {

View File

@@ -6,7 +6,7 @@
}"
>
<template #body-header>
<div class="text-lg font-semibold">
<div class="text-lg font-semibold text-ink-gray-9">
{{
gatewayID === 'new'
? __('New Payment Gateway')

View File

@@ -43,6 +43,7 @@
? { sections: activeTab.sections }
: {}),
...(activeTab.label == 'Members' ||
activeTab.label == 'Evaluators' ||
activeTab.label == 'Transactions'
? { 'onUpdate:show': (val) => (show = val), show }
: {}),

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
@@ -87,7 +85,7 @@
/>
</div>
<div class="font-semibold mt-10">
<div class="font-semibold mt-10 text-ink-gray-9">
{{ __('Payment Details') }}
</div>
<div class="grid grid-cols-3 gap-5 mt-5">
@@ -111,7 +109,7 @@
</div>
<div v-if="transactionData.coupon">
<div class="font-semibold mt-10">
<div class="font-semibold mt-10 text-ink-gray-9">
{{ __('Coupon Details') }}
</div>
<div class="grid grid-cols-3 gap-5 mt-5">
@@ -142,7 +140,7 @@
</div>
</div>
<div class="font-semibold mt-10">
<div class="font-semibold mt-10 text-ink-gray-9">
{{ __('Billing Details') }}
</div>
<div class="grid grid-cols-3 gap-5 mt-5">

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 gap-x-4 rounded bg-surface-gray-2 p-2"

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out border-e bg-surface-menu-bar"
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out border-e bg-surface-menu-bar overflow-x-hidden"
:class="sidebarStore.isSidebarCollapsed ? 'w-14' : 'w-56'"
>
<div
@@ -8,7 +8,7 @@
:class="sidebarStore.isSidebarCollapsed ? 'items-center' : ''"
>
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
<div class="flex flex-col overflow-y-auto" v-if="sidebarSettings.data">
<div class="flex flex-col" v-if="sidebarSettings.data">
<div v-for="link in sidebarLinks" class="mx-2 my-2.5">
<div
v-if="!link.hideLabel"

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,17 @@
</Button>
</header>
<div class="md:w-3/4 md:mx-auto 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="py-5">
<div
class="flex flex-col md:flex-row md:items-center space-y-4 md:space-y-0 justify-between mb-5 mx-5"
>
<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 +47,77 @@
row-key="name"
:options="{
showTooltip: false,
selectable: false,
selectable: true,
onRowClick: (row) => {
if (readOnlyMode) return
assignmentID = row.name
showAssignmentForm = true
},
}"
class="h-[71vh] lg:h-[79vh] px-5"
>
<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 v-else class="h-[53vh]">
<EmptyState type="Assignments" />
</div>
<div class="flex items-center justify-end space-x-3 pt-3 border-t px-5">
<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 +132,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 +158,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 +171,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 +183,10 @@ watch([titleFilter, typeFilter], () => {
},
})
reloadAssignments()
totalAssignments.update({
filters: assignmentFilter.value,
})
totalAssignments.reload()
})
const reloadAssignments = () => {
@@ -137,7 +201,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 +209,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: __('Updated On'),
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 +271,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

@@ -12,9 +12,9 @@
</Button>
</router-link>
</header>
<div class="mx-auto w-full max-w-4xl pt-6 pb-10">
<div class="flex flex-col md:flex-row justify-between mb-8 px-3">
<div class="text-xl font-semibold text-ink-gray-9 mb-4 md:mb-0">
<div class="mx-auto w-full">
<div class="flex flex-col md:flex-row justify-between mb-5 px-5 pt-5">
<div class="text-lg font-semibold text-ink-gray-9 mb-4 md:mb-0">
{{ memberCount }} {{ __('Certified Members') }}
</div>
<div
@@ -41,99 +41,99 @@
</div>
</div>
<div class="flex items-center gap-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>
</div>
</div>
<div v-if="participants.data?.length" class="">
<template v-for="(participant, index) in participants.data">
<router-link
:to="{
name: 'ProfileAbout',
params: {
username: participant.username,
},
}"
<div
v-if="participants.data?.length"
class="h-[63vh] lg:h-[77vh] overflow-y-auto mb-5 px-5"
>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5">
<div
v-for="participant in participants.data"
class="flex flex-col border hover:border-outline-gray-3 rounded-lg p-3 text-ink-gray-9 cursor-pointer"
@click="
router.push({
name: 'ProfileAbout',
params: { username: participant.username },
})
"
>
<div class="rounded-md hover:bg-surface-gray-2 px-3">
<div
class="flex items-center w-full gap-x-3 py-2"
:class="{
'border-b': index < participants.data.length - 1,
}"
>
<UserAvatar :user="participant" size="2xl" />
<div class="flex flex-col md:flex-row w-full">
<div class="flex-1">
<div class="text-base font-medium text-ink-gray-8">
{{ participant.full_name }}
</div>
<div
v-if="participant.headline"
class="mt-1.5 text-base text-ink-gray-5"
>
{{ participant.headline }}
</div>
</div>
<div
class="flex items-center gap-x-3 md:gap-x-24 text-sm md:text-base mt-1.5"
>
<div class="text-ink-gray-5">
{{ participant.certificate_count }}
{{
participant.certificate_count > 1
? __('certificates')
: __('certificate')
}}
</div>
<span class="text-ink-gray-4 md:hidden">·</span>
<div class="text-ink-gray-5">
{{ dayjs(participant.issue_date).format('DD MMM YYYY') }}
</div>
</div>
<div class="flex items-center gap-x-4">
<UserAvatar :user="participant" size="2xl" />
<div class="flex flex-col">
<div class="font-semibold line-clamp-1">
{{ participant.full_name }}
</div>
<div class="text-sm leading-5 line-clamp-1 mb-4">
{{
participant.headline ||
'Joined ' + dayjs(participant.creation).fromNow()
}}
</div>
</div>
</div>
</router-link>
</template>
<div class="mt-auto space-y-2 text-ink-gray-7">
<div class="flex items-center gap-x-1">
<GraduationCap class="h-4 w-4 stroke-1.5 me-1" />
<span>
{{ participant.certificate_count }}
{{
participant.certificate_count > 1
? __('certificates')
: __('certificate')
}}
</span>
</div>
<div class="flex items-center gap-x-1">
<Calendar class="h-4 w-4 stroke-1.5 me-1" />
<span>{{
dayjs(participant.issue_date).format('DD MMM YYYY')
}}</span>
</div>
</div>
</div>
</div>
</div>
<EmptyState v-else type="Certified Members" />
<div
v-if="!participants.list.loading && participants.hasNextPage"
class="flex justify-center mt-5"
>
<Button @click="participants.next()">
<div v-else class="h-[40vh] lg:h-[53vh] px-5">
<EmptyState type="Certified Members" />
</div>
<div class="flex items-center justify-end gap-x-3 border-t pt-3 px-5">
<Button v-if="participants.hasNextPage" @click="participants.next()">
{{ __('Load More') }}
</Button>
<div v-if="participants.hasNextPage" class="h-8 border-s"></div>
<div class="text-ink-gray-5">
{{ participants.data?.length }} {{ __('of') }}
{{ memberCount }}
</div>
</div>
</div>
</template>
<script setup>
import {
Avatar,
Breadcrumbs,
Button,
call,
createListResource,
FormControl,
Select,
Switch,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { GraduationCap } from 'lucide-vue-next'
import { GraduationCap, Calendar } from 'lucide-vue-next'
import { sessionStore } from '../stores/session'
import { useRouter } from 'vue-router'
import EmptyState from '@/components/EmptyState.vue'
@@ -163,8 +163,8 @@ const participants = createListResource({
doctype: 'LMS Certificate',
url: 'lms.lms.api.get_certified_participants',
start: 0,
pageLength: 40,
cache: ['certified_participants'],
pageLength: 100,
})
const getMemberCount = () => {

View File

@@ -4,15 +4,19 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
<div v-if="tabIndex == 2" class="flex items-center gap-x-2">
<div v-if="tabIndex == 2 && isAdmin" class="flex items-center gap-x-2">
<Badge v-if="childRef?.isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
<Button @click="childRef.trashCourse()">
<template #icon>
<Trash2 class="w-4 h-4 stroke-1.5" />
<Dropdown :options="courseMenu" side="left">
<template v-slot="{ open }">
<Button>
<template #icon>
<Ellipsis class="w-4 h-4 stroke-1.5" />
</template>
</Button>
</template>
</Button>
</Dropdown>
<Button variant="solid" @click="childRef.submitCourse()">
{{ __('Save') }}
</Button>
@@ -31,16 +35,26 @@
<script setup>
import {
Badge,
Button,
createResource,
Breadcrumbs,
Button,
call,
createResource,
Dropdown,
Tabs,
toast,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, markRaw, onMounted, ref, watch } from 'vue'
import { sessionStore } from '@/stores/session'
import { useRouter, useRoute } from 'vue-router'
import { List, Settings2, Trash2, TrendingUp } from 'lucide-vue-next'
import {
Download,
Ellipsis,
List,
Settings2,
Trash2,
TrendingUp,
} from 'lucide-vue-next'
import CourseOverview from '@/pages/Courses/CourseOverview.vue'
import CourseDashboard from '@/pages/Courses/CourseDashboard.vue'
import CourseForm from '@/pages/Courses/CourseForm.vue'
@@ -139,6 +153,73 @@ const isAdmin = computed(() => {
return user.data?.is_moderator || isInstructor()
})
const exportCourse = async () => {
try {
const response = await fetch(
'/api/method/lms.lms.api.export_course_as_zip?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')
}
const blob = await response.blob()
const disposition = response.headers.get('Content-Disposition')
let filename = 'course.zip'
if (disposition && disposition.includes('filename=')) {
filename = disposition.split('filename=')[1].replace(/"/g, '')
}
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
} catch (err) {
console.error(err)
toast.error('Export failed')
}
}
const download_course_zip = (data) => {
const a = document.createElement('a')
a.href = data.export_url
a.download = data.name
a.click()
}
const courseMenu = computed(() => {
let options = [
{
label: __('Export'),
onClick() {
exportCourse()
},
icon: Download,
},
{
label: __('Delete'),
onClick() {
childRef.value.trashCourse()
},
icon: Trash2,
},
]
return options
})
const breadcrumbs = computed(() => {
let crumbs = [{ label: __('Courses'), route: { name: 'Courses' } }]
crumbs.push({

View File

@@ -0,0 +1,201 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Import Course from ZIP'),
}"
>
<template #body-content>
<div class="text-p-base">
<div
v-if="!zip"
@dragover.prevent
@drop.prevent="(e) => uploadFile(e)"
class="h-[120px] flex flex-col items-center justify-center bg-surface-gray-1 border border-dashed border-outline-gray-3 rounded-md"
>
<div v-if="!uploading" class="w-4/5 text-center">
<UploadCloud
class="size-6 stroke-1.5 text-ink-gray-6 mx-auto mb-2.5"
/>
<input
ref="fileInput"
type="file"
class="hidden"
accept=".zip"
@change="(e) => uploadFile(e)"
/>
<div class="leading-5 text-ink-gray-9">
{{ __('Drag and drop a ZIP file, or upload from your') }}
<span
@click="openFileSelector"
class="cursor-pointer font-semibold hover:underline"
>
{{ __('Device') }}
</span>
</div>
</div>
<div
v-else-if="uploading"
class="w-fit bg-surface-white border rounded-md p-2 my-4"
>
<div class="space-y-2">
<div class="font-medium">
{{ uploadingFile.name }}
</div>
<div class="text-ink-gray-6">
{{ convertToMB(uploaded) }} of {{ convertToMB(total) }}
</div>
</div>
<div class="w-full bg-surface-gray-1 h-1 rounded-full mt-3">
<div
class="bg-surface-gray-7 h-1 rounded-full transition-all duration-500 ease-in-out"
:style="`width: ${uploadProgress}%`"
></div>
</div>
</div>
</div>
<div
v-else-if="zip"
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 mx-5"
>
<div class="space-y-2">
<div class="font-medium leading-5 text-ink-gray-9">
{{ zip.file_name || zip.name }}
</div>
<div v-if="zip.file_size" class="text-ink-gray-6">
{{ convertToMB(zip.file_size) }}
</div>
</div>
<Trash2
class="size-4 stroke-1.5 text-ink-red-3 cursor-pointer"
@click="deleteFile"
/>
</div>
</div>
</div>
</template>
<template #actions>
<div class="flex justify-end">
<Button variant="solid" @click="importZip">
{{ __('Import') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, call, Dialog, FileUploadHandler, toast } from 'frappe-ui'
import { computed, ref } from 'vue'
import { Trash2, UploadCloud } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
const fileInput = ref<HTMLInputElement | null>(null)
const show = defineModel<boolean>({ required: true, default: false })
const zip = ref<any | null>(null)
const uploaded = ref(0)
const total = ref(0)
const uploading = ref(false)
const uploadingFile = ref<any | null>(null)
const router = useRouter()
const openFileSelector = () => {
fileInput.value?.click()
}
const uploadProgress = computed(() => {
if (total.value === 0) return 0
return Math.floor((uploaded.value / total.value) * 100)
})
const extractFile = (e: Event): File | null => {
const inputFiles = (e.target as HTMLInputElement)?.files
const dt = (e as DragEvent).dataTransfer?.files
return inputFiles?.[0] || dt?.[0] || null
}
const validateFile = (file: File) => {
const extension = file.name.split('.').pop()?.toLowerCase()
if (extension !== 'zip') {
toast.error('Please upload a valid ZIP file.')
console.error('Please upload a valid ZIP file.')
}
return extension
}
const uploadFile = (e: Event) => {
const file = extractFile(e)
if (!file) return
let fileType = validateFile(file)
if (fileType !== 'zip') return
uploadingFile.value = file
const uploader = new FileUploadHandler()
uploader.on('start', () => {
uploading.value = true
})
uploader.on('progress', (data: { uploaded: number; total: number }) => {
uploaded.value = data.uploaded
total.value = data.total
})
uploader.on('error', (error: any) => {
uploading.value = false
toast.error(__('File upload failed. Please try again.'))
console.error('File upload error:', error)
})
uploader.on('finish', () => {
uploading.value = false
})
uploader
.upload(file, {
private: 1,
})
.then((data: any) => {
zip.value = data
})
.catch((error: any) => {
console.error('File upload error:', error)
toast.error(__('File upload failed. Please try again.'))
uploading.value = false
uploadingFile.value = null
uploaded.value = 0
total.value = 0
})
}
const importZip = () => {
if (!zip.value) return
call('lms.lms.api.import_course_from_zip', {
zip_file_path: zip.value.file_url,
})
.then((data: any) => {
toast.success('Course imported successfully!')
show.value = false
deleteFile()
router.push({
name: 'CourseDetail',
params: { courseName: data },
})
})
.catch((error: any) => {
toast.error('Error importing course: ' + error.message)
console.error('Error importing course:', error)
})
}
const deleteFile = () => {
zip.value = null
}
const convertToMB = (bytes: number) => {
return (bytes / 1024 / 1024).toFixed(2) + ' MB'
}
</script>

View File

@@ -8,25 +8,7 @@
placement="right"
side="bottom"
v-if="canCreateCourse()"
:options="[
{
label: __('New Course'),
icon: 'book-open',
onClick() {
showCourseModal = true
},
},
{
label: __('Import Course'),
icon: 'upload',
onClick() {
router.push({
name: 'NewDataImport',
params: { doctype: 'LMS Course' },
})
},
},
]"
:options="courseMenu"
>
<template v-slot="{ open }">
<Button variant="solid">
@@ -113,6 +95,11 @@
v-model="showCourseModal"
:courses="courses"
/>
<CourseImportModal
v-if="showCourseImportModal"
v-model="showCourseImportModal"
/>
</template>
<script setup>
import {
@@ -135,6 +122,7 @@ import CourseCard from '@/components/CourseCard.vue'
import EmptyState from '@/components/EmptyState.vue'
import { useRouter } from 'vue-router'
import NewCourseModal from '@/pages/Courses/NewCourseModal.vue'
import CourseImportModal from '@/pages/Courses/CourseImportModal.vue'
const user = inject('$user')
const dayjs = inject('$dayjs')
@@ -155,6 +143,7 @@ const { brand } = sessionStore()
const courseCount = ref(0)
const router = useRouter()
const showCourseModal = ref(false)
const showCourseImportModal = ref(false)
onMounted(() => {
setFiltersFromQuery()
@@ -351,6 +340,35 @@ const courseTabs = computed(() => {
return tabs
})
const courseMenu = computed(() => {
return [
{
label: __('New Course'),
icon: 'book-open',
onClick() {
showCourseModal.value = true
},
},
{
label: __('Import via Data Import Tool'),
icon: 'upload',
onClick() {
router.push({
name: 'NewDataImport',
params: { doctype: 'LMS Course' },
})
},
},
{
label: __('Import via ZIP'),
icon: 'folder-plus',
onClick() {
showCourseImportModal.value = true
},
},
]
})
const breadcrumbs = computed(() => [
{
label: __('Courses'),

View File

@@ -15,12 +15,21 @@
]"
/>
</header>
<div class="max-w-4xl mx-auto pt-5 p-4">
<div class="mb-6">
<h1 class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
{{ applicationCount }}
{{ applicationCount === 1 ? __('Application') : __('Applications') }}
</h1>
<div class="mx-auto pt-5 p-4">
<div class="flex items-center justify-between mb-5">
<div class="text-lg font-semibold text-ink-gray-9 mb-4 md:mb-0">
{{ totalApplications.data }}
{{
totalApplications.data === 1
? __('Application')
: __('Applications')
}}
</div>
<FormControl v-model="search" type="text" placeholder="Search">
<template #prefix>
<FeatherIcon name="search" class="size-4 text-ink-gray-5" />
</template>
</FormControl>
</div>
<div v-if="applications.data?.length">
@@ -32,9 +41,10 @@
showTooltip: false,
selectable: false,
}"
class="h-[79vh] border-b"
>
<ListHeader
class="mb-2 grid items-center gap-x-4 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"
@@ -70,10 +80,7 @@
<span>{{ item }}</span>
</div>
<div
v-else-if="column.key === 'actions'"
class="flex justify-center"
>
<div v-else-if="column.key === 'actions'">
<Dropdown :options="getActionOptions(row)">
<Button variant="ghost">
<FeatherIcon name="more-horizontal" class="w-4 h-4" />
@@ -93,13 +100,15 @@
</ListRow>
</ListRows>
</ListView>
<div class="flex justify-center mt-5">
<div class="flex items-center justify-end gap-x-3 mt-3">
<Button v-if="applications.hasNextPage" @click="applications.next()">
<template #prefix>
<RefreshCw class="size-4 stroke-1.5" />
</template>
{{ __('Load More') }}
</Button>
<div v-if="applications.hasNextPage" class="h-8 border-s"></div>
<div class="text-ink-gray-5">
{{ applications.data?.length }} {{ __('of') }}
{{ totalApplications.data }}
</div>
</div>
</div>
<EmptyState v-else-if="!applications.loading" type="Job Applications" />
@@ -172,8 +181,7 @@ import {
usePageMeta,
toast,
} from 'frappe-ui'
import { RefreshCw } from 'lucide-vue-next'
import { computed, inject, onMounted, ref, reactive } from 'vue'
import { computed, inject, ref, reactive, watch } from 'vue'
import { sessionStore } from '../stores/session'
import EmptyState from '@/components/EmptyState.vue'
@@ -181,7 +189,7 @@ const dayjs = inject('$dayjs')
const { brand } = sessionStore()
const showEmailModal = ref(false)
const selectedApplicant = ref(null)
const applicationCount = ref(0)
const search = ref('')
const emailForm = reactive({
subject: '',
message: '',
@@ -195,19 +203,6 @@ const props = defineProps({
},
})
onMounted(() => {
getApplicationCount()
})
const getApplicationCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Job Application',
filters: { job: props.job },
}).then((count) => {
applicationCount.value = count
})
}
const applications = createListResource({
doctype: 'LMS Job Application',
fields: [
@@ -225,6 +220,37 @@ const applications = createListResource({
auto: true,
})
const totalApplications = createResource({
url: 'frappe.client.get_count',
params: {
doctype: 'LMS Job Application',
filters: {
job: props.job,
},
},
auto: true,
cache: ['totalApplications', props.job],
onError(err) {
toast.error(err.messages?.[0] || err)
console.error('Error fetching total applications:', err)
},
})
watch(search, () => {
let filters = {
job: props.job,
user: ['like', `%${search.value}%`],
}
applications.update({
filters: filters,
})
applications.reload()
totalApplications.update({
filters: filters,
})
totalApplications.reload()
})
const emailResource = createResource({
url: 'frappe.core.doctype.communication.email.make',
makeParams(values) {
@@ -298,25 +324,26 @@ const applicationColumns = computed(() => {
{
label: __('Full Name'),
key: 'full_name',
width: 2,
width: 3,
icon: 'user',
},
{
label: __('Email'),
key: 'email',
width: 2,
width: 3,
icon: 'at-sign',
},
{
label: __('Applied On'),
key: 'applied_on',
width: 1,
width: 2,
icon: 'calendar',
},
{
label: '',
key: 'actions',
width: 1,
align: 'right',
},
]
})
@@ -326,7 +353,7 @@ const applicantRows = computed(() => {
return applications.data.map((application) => ({
...application,
full_name: application.full_name,
applied_on: dayjs(application.creation).fromNow(),
applied_on: dayjs(application.creation).format('DD MMM YYYY'),
}))
})

View File

@@ -13,13 +13,13 @@
</Button>
</div>
</header>
<div class="py-5">
<div class="container border-b mb-4 pb-5">
<div class="text-lg font-semibold mb-4 text-ink-gray-9">
{{ __('Job Details') }}
</div>
<div class="grid grid-cols-2 gap-5">
<div class="space-y-4">
<div class="">
<div class="grid grid-cols-[70%,30%] gap-5 px-5">
<div class="space-y-5 pt-5">
<div class="text-ink-gray-9 font-semibold">
{{ __('Job Details') }}
</div>
<div class="grid grid-cols-3 gap-5">
<FormControl
v-model="job.job_title"
:label="__('Title')"
@@ -40,7 +40,34 @@
:required="true"
/>
</div>
<div class="space-y-4">
<div>
<label class="block text-ink-gray-5 text-xs mb-1">
{{ __('Description') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="job.description"
@change="(val) => (job.description = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[20rem] max-h-[70vh] overflow-y-auto mb-4"
/>
</div>
</div>
<div class="border-s h-[93vh]">
<div v-if="jobName != 'new'" class="p-5 space-y-5 border-b">
<FormControl
v-model="job.status"
:label="__('Status')"
type="select"
:options="jobStatuses"
:required="true"
/>
</div>
<div class="p-5 space-y-5 border-b">
<div class="text-ink-gray-9 font-semibold">
{{ __('Location') }}
</div>
<FormControl
v-model="job.location"
:label="__('City')"
@@ -52,23 +79,11 @@
:label="__('Country')"
:required="true"
/>
<FormControl
v-if="jobName != 'new'"
v-model="job.status"
:label="__('Status')"
type="select"
:options="jobStatuses"
:required="true"
/>
</div>
</div>
</div>
<div class="container border-b mb-4 pb-5">
<div class="text-lg font-semibold mb-4 text-ink-gray-9">
{{ __('Company Details') }}
</div>
<div class="grid grid-cols-2 gap-5">
<div>
<div class="p-5 space-y-5">
<div class="text-ink-gray-9 font-semibold">
{{ __('Company Details') }}
</div>
<FormControl
v-model="job.company_name"
:label="__('Company Name')"
@@ -80,8 +95,6 @@
:label="__('Company Website')"
:required="true"
/>
</div>
<div>
<FormControl
v-model="job.company_email_address"
:label="__('Company Email Address')"
@@ -96,19 +109,6 @@
</div>
</div>
</div>
<div class="container mt-4">
<label class="block text-ink-gray-5 text-xs mb-1">
{{ __('Description') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="job.description"
@change="(val) => (job.description = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
/>
</div>
</div>
</div>
</template>

View File

@@ -22,17 +22,17 @@
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('New Job') }}
{{ __('Create') }}
</Button>
</router-link>
</header>
<div>
<div
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto mb-2 p-5"
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full mx-auto mb-2 p-5"
>
<div class="flex items-center justify-between">
<div class="text-xl font-semibold text-ink-gray-9 md:mb-0">
{{ __('{0} {1} Jobs').format(jobCount, activeTab) }}
<div class="text-lg font-semibold text-ink-gray-9 md:mb-0">
{{ __('{0} {1} Jobs').format(jobCount.data, activeTab) }}
</div>
<TabButtons
v-if="tabs.length > 1"
@@ -96,8 +96,11 @@
</div>
</div>
</div>
<div v-if="jobs.data?.length" class="w-full md:w-4/5 mx-auto p-5 pt-0">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div
v-if="jobs.data?.length"
class="w-full h-[61vh] lg:h-[78vh] overflow-y-auto mx-auto p-5 pt-0"
>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<router-link
v-for="job in jobs.data"
:to="{
@@ -110,7 +113,19 @@
</router-link>
</div>
</div>
<EmptyState v-else type="Job Openings" />
<div v-else class="h-[32vh] lg:h-[50vh] px-5">
<EmptyState type="Job Openings" />
</div>
<div class="flex items-center justify-end gap-x-3 border-t pt-3 px-5">
<Button v-if="jobs.hasNextPage" @click="jobs.next()">
{{ __('Load More') }}
</Button>
<div v-if="jobs.hasNextPage" class="h-8 border-s"></div>
<div class="text-ink-gray-5">
{{ jobs.data?.length }} {{ __('of') }}
{{ jobCount.data }}
</div>
</div>
</div>
</div>
</template>
@@ -119,6 +134,7 @@ import {
Button,
Breadcrumbs,
call,
createListResource,
createResource,
FormControl,
TabButtons,
@@ -141,7 +157,6 @@ const searchQuery = ref('')
const country = ref(null)
const filters = ref({})
const orFilters = ref({})
const jobCount = ref(0)
const closedJobs = ref(0)
const activeTab = ref('Open')
const readOnlyMode = window.read_only_mode
@@ -157,9 +172,7 @@ const isModerator = computed(() => {
})
const getClosedJobCount = () => {
if (!user.data?.name) {
return
}
if (!user.data?.name) return
const filters = {
status: 'Closed',
@@ -177,6 +190,14 @@ const getClosedJobCount = () => {
})
}
const jobCount = createResource({
url: 'frappe.client.get_count',
params: {
doctype: 'Job Opportunity',
filters: filters.value,
},
})
const setFiltersFromURL = () => {
let queries = new URLSearchParams(location.search)
if (queries.has('type')) {
@@ -187,53 +208,54 @@ const setFiltersFromURL = () => {
}
}
const tabs = computed(() => {
const tabsArray = [
{
label: __('Open'),
},
]
if (closedJobs.value) {
tabsArray.push({
label: __('Closed'),
})
}
return tabsArray
})
const jobs = createResource({
const jobs = createListResource({
url: 'lms.lms.api.get_job_opportunities',
doctype: 'Job Opportunity',
start: 0,
cache: ['jobs'],
pageLength: 40,
})
const updateJobs = () => {
updateFilters()
jobs.update({
params: {
filters: filters.value,
orFilters: orFilters.value,
},
filters: filters.value,
orFilters: orFilters.value,
})
jobs.reload()
jobCount.update({
filters: filters.value,
orFilters: orFilters.value,
})
jobCount.reload()
}
const updateFilters = () => {
filters.value.status = 'Open'
updateJobTypeFilter()
updateWorkModeFilter()
updateSearchQueryFilter()
updateCountryFilter()
updateTabFilter()
}
const updateJobTypeFilter = () => {
if (jobType.value && jobType.value !== ' ') {
filters.value.type = jobType.value
} else {
delete filters.value.type
}
}
const updateWorkModeFilter = () => {
if (workMode.value && workMode.value !== ' ') {
filters.value.work_mode = workMode.value
} else {
delete filters.value.work_mode
}
}
const updateSearchQueryFilter = () => {
if (searchQuery.value) {
orFilters.value = {
job_title: ['like', `%${searchQuery.value}%`],
@@ -243,13 +265,17 @@ const updateFilters = () => {
} else {
orFilters.value = {}
}
}
const updateCountryFilter = () => {
if (country.value) {
filters.value.country = country.value
} else {
delete filters.value.country
}
}
const updateTabFilter = () => {
if (activeTab.value === 'Closed') {
filters.value.status = 'Closed'
if (!isModerator.value) {
@@ -269,8 +295,20 @@ watch(country, (val) => {
updateJobs()
})
watch(jobs, () => {
jobCount.value = jobs.data?.length || 0
const tabs = computed(() => {
const tabsArray = [
{
label: __('Open'),
},
]
if (closedJobs.value) {
tabsArray.push({
label: __('Closed'),
})
}
return tabsArray
})
const jobTypes = computed(() => {
@@ -286,9 +324,9 @@ const jobTypes = computed(() => {
const workModes = computed(() => {
return [
{ label: ' ', value: ' ' },
{ label: 'On site', value: 'On-site' },
{ label: 'Hybrid', value: 'Hybrid' },
{ label: 'Remote', value: 'Remote' },
{ label: __('On-site'), value: 'On-site' },
{ label: __('Hybrid'), value: 'Hybrid' },
{ label: __('Remote'), value: 'Remote' },
]
})

View File

@@ -1,9 +1,11 @@
<template>
<header
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
class="sticky top-0 z-10 flex flex-row items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<div class="flex items-center gap-x-2">
<div class="flex-1">
<Breadcrumbs :items="breadcrumbs" />
</div>
<div class="flex items-center gap-x-2 shrink-0">
<Button
@click="markAllAsRead.submit"
:loading="markAllAsRead.loading"
@@ -18,12 +20,12 @@
/>
</div>
</header>
<div class="w-full md:w-3/4 mx-auto px-5 pt-6 divide-y">
<div class="w-full md:w-3/4 mx-auto px-3 sm:px-5 pt-4 sm:pt-6 divide-y">
<div
v-if="notifications?.length"
v-for="log in notifications"
:key="log.name"
class="flex gap-x-2 px-2 py-4"
class="flex items-center gap-x-2 px-2 py-4"
:class="{
'cursor-pointer': log.link,
'items-center': !showDetails(log) && !isMentionOrComment(log),
@@ -44,6 +46,8 @@
<div class="text-sm text-ink-gray-5">
{{ dayjs(log.creation).fromNow() }}
</div>
</div>
<div class="flex items-center gap-x-2 shrink-0">
<Button
variant="ghost"
v-if="!log.read"
@@ -90,10 +94,10 @@
: __('New Batch')
}}
</div>
<div class="font-semibold mb-1">
<div class="font-semibold mb-1 text-ink-gray-9">
{{ __(log.document_details.title) }}
</div>
<div class="leading-5">
<div class="leading-5 text-ink-gray-7">
{{ __(log.document_details.short_introduction) }}
</div>
<div
@@ -130,7 +134,7 @@
:image="instructor.user_image"
:label="instructor.full_name"
/>
<span class="font-medium text-sm">
<span class="font-medium text-sm text-ink-gray-9">
{{ instructor.full_name }}
</span>
</div>
@@ -139,8 +143,22 @@
</div>
</div>
</div>
<div v-else class="text-ink-gray-5">
{{ __('Nothing to see here.') }}
<div v-else class="flex flex-col items-center justify-center mt-60">
<Bell class="size-10 mx-auto stroke-1 text-ink-gray-5" />
<p class="text-lg font-semibold text-ink-gray-7 mb-2.5">
{{
activeTab === 'Unread'
? __('No unread notifications')
: __('No read notifications')
}}
</p>
<p class="text-p-base w-full md:w-2/5 text-center text-ink-gray-7">
{{
activeTab === 'Unread'
? __("You're all caught up! Check back later for updates.")
: __('Notifications you have read will appear here.')
}}
</p>
</div>
</div>
</template>
@@ -158,7 +176,7 @@ import {
import { sessionStore } from '../stores/session'
import { computed, inject, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Calendar, Clock, X } from 'lucide-vue-next'
import { Bell, Calendar, Clock, X } from 'lucide-vue-next'
import { formatTime } from '@/utils/'
const { brand } = sessionStore()

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

@@ -6,6 +6,7 @@
<div class="flex gap-2">
<router-link
v-if="exercises.data?.length"
class="hidden md:block"
:to="{
name: 'ProgrammingExerciseSubmissions',
}"
@@ -34,10 +35,12 @@
</Button>
</div>
</header>
<div class="p-5">
<div class="flex items-center justify-between mb-5">
<div class="py-5">
<div
class="flex flex-col md:flex-row md:items-center space-y-4 md:space-y-0 justify-between mb-5 px-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 +72,10 @@
showForm = true
},
}"
class="h-[71vh] lg:h-[79vh] px-5"
>
<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 }">
@@ -114,21 +118,24 @@
</ListSelectBanner>
</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 v-else class="h-[45vh] lg:h-[53vh] px-5">
<EmptyState type="Programming Exercises" />
</div>
<div class="flex items-center justify-end gap-x-3 px-5 pt-3 border-t">
<Button v-if="exercises.hasNextPage" @click="exercises.next()">
{{ __('Load More') }}
</Button>
<div v-if="exercises.hasNextPage" class="h-8 border-s"></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 +144,7 @@ import {
Breadcrumbs,
Button,
call,
createResource,
createListResource,
dayjs,
FeatherIcon,
@@ -156,7 +164,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 +177,6 @@ const { $dialog } = app?.appContext.config.globalProperties
onMounted(() => {
validatePermissions()
getExerciseCount()
})
const validatePermissions = () => {
@@ -185,19 +191,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 +205,10 @@ const updateList = () => {
filters: filters,
})
exercises.reload()
getExerciseCount(filters)
totalExercises.update({
filters: filters,
})
totalExercises.reload()
}
const getFilters = () => {
@@ -266,6 +262,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 +287,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 +302,7 @@ const columns = computed(() => {
key: 'modified',
width: 1,
icon: 'clock',
align: 'right',
},
]
})

View File

@@ -10,7 +10,7 @@
{{ __('New') }}
</Button>
</header>
<div v-if="programs.data?.length && !isStudent" class="py-10 w-3/4 mx-auto">
<div v-if="programs.data?.length && !isStudent" class="py-10 px-5">
<div class="text-lg font-semibold text-ink-gray-9 mb-5">
{{
__('{0} {1}').format(
@@ -19,7 +19,7 @@
)
}}
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5">
<div
v-for="program in programs.data"
@click="openForm(program.name)"

View File

@@ -10,8 +10,8 @@
{{ __('Create') }}
</Button>
</header>
<div class="pt-5 mx-5">
<div class="flex items-center justify-between mb-5">
<div class="pt-5">
<div class="flex items-center justify-between mb-5 mx-5">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('{0} Quizzes').format(quizzes.data?.length) }}
</div>
@@ -27,7 +27,7 @@
:rows="quizzes.data"
row-key="name"
:options="{ showTooltip: false, selectable: true }"
class="h-[79vh] border-b"
class="h-[74.5vh] lg:h-[79vh] px-5"
>
<ListHeader
class="mb-2 grid items-center rounded bg-surface-white border-b rounded-none p-2"
@@ -85,8 +85,10 @@
</template>
</ListSelectBanner>
</ListView>
<EmptyState v-else type="Quizzes" />
<div class="flex items-center justify-end gap-x-3 mt-3">
<div v-else class="h-[49vh] lg:h-[53vh] px-5">
<EmptyState type="Quizzes" />
</div>
<div class="flex items-center justify-end gap-x-3 pt-3 border-t px-5">
<Button v-if="quizzes.hasNextPage" @click="quizzes.next()">
{{ __('Load More') }}
</Button>
@@ -184,6 +186,7 @@ watch(search, () => {
totalQuizzes.update({
filters: quizFilters.value,
})
totalQuizzes.reload()
})
const quizzes = createListResource({
@@ -205,7 +208,7 @@ const quizzes = createListResource({
return data.map((quiz) => {
return {
...quiz,
modified: dayjs(quiz.modified).fromNow(true),
modified: dayjs(quiz.modified).format('DD MMM YYYY'),
}
})
},
@@ -295,7 +298,7 @@ const quizColumns = computed(() => {
{
label: __('Show Answers'),
key: 'show_answers',
width: 1,
width: 0.5,
align: 'center',
icon: 'eye',
},
@@ -303,7 +306,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
}
@@ -657,6 +659,8 @@ export const validateFile = async (
return error(
__('Only document file of type .doc or .docx are allowed.')
)
} else if (fileType == 'zip' && extension != 'zip') {
return error(__('Only ZIP files are allowed.'))
} else if (
['image', 'video'].includes(fileType) &&
!file.type.startsWith(`${fileType}/`)

View File

@@ -46,7 +46,7 @@ export class Quiz {
renderQuiz(quiz) {
if (this.readOnly) {
const quizPath = getLmsRoute(`quiz/${quiz}?fromLesson=1`)
this.wrapper.innerHTML = `<iframe src="${quizPath}" class="w-full h-[500px]"></iframe>`
this.wrapper.innerHTML = `<iframe src="${quizPath}" class="w-full h-[700px]"></iframe>`
return
}
this.wrapper.innerHTML = `<div class='border rounded-md p-4 text-center bg-surface-menu-bar mb-4'>

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

@@ -3,14 +3,32 @@ import json
import frappe
from lms.lms.doctype.lms_course.lms_course import update_course_statistics
from lms.lms.utils import get_course_progress
from lms.lms.utils import create_user, get_course_progress
def create_demo_data(args: dict = None):
course = create_course()
student = create_user("Ashley", "Ippolito", "ash@ipp.com", "/assets/lms/images/student.jpg")
student1 = create_user("John", "Doe", "john.doe@example.com", "/assets/lms/images/student1.jpeg")
student2 = create_user("Jane", "Smith", "jane.smith@example.com", "/assets/lms/images/student2.jpeg")
student = create_user(
email="ash@ipp.com",
first_name="Ashley",
last_name="Ippolito",
full_name="Ashley Ippolito",
user_image="/assets/lms/images/student.jpg",
)
student1 = create_user(
email="john.doe@example.com",
first_name="John",
last_name="Doe",
full_name="John Doe",
user_image="/assets/lms/images/student1.jpeg",
)
student2 = create_user(
email="jane.smith@example.com",
first_name="Jane",
last_name="Smith",
full_name="Jane Smith",
user_image="/assets/lms/images/student2.jpeg",
)
create_chapter(course)
create_lessons(course)
enroll_student_in_course(student, course)
@@ -93,29 +111,14 @@ def create_instructor():
return instructor
return create_user(
"Jannat", "Patel", "jannat@example.com", "/assets/lms/images/instructor.png", ["Moderator"]
email="jannat@example.com",
first_name="Jannat",
last_name="Patel",
user_image="/assets/lms/images/instructor.png",
roles=["Moderator"],
)
def create_user(first_name, last_name, email, user_image, roles=None):
if roles is None:
roles = ["LMS Student"]
filters = {"first_name": first_name, "last_name": last_name, "email": email}
if frappe.db.exists("User", filters):
return frappe.get_doc("User", filters)
user = frappe.new_doc("User")
user.first_name = first_name
user.last_name = last_name
user.user_image = user_image
user.email = email
user.send_welcome_email = False
user.add_roles(*roles)
user.save()
return user
def create_chapter(course):
prepare_chapter(course, "Introduction")
prepare_chapter(course, "Adding content to your lessons")

View File

@@ -29,17 +29,16 @@ from frappe.utils import (
from frappe.utils.response import Response
from pypika import functions as fn
from lms.lms.course_import_export import export_course_zip, import_course_zip
from lms.lms.doctype.course_lesson.course_lesson import save_progress
from lms.lms.utils import (
LMS_ROLES,
can_modify_batch,
can_modify_course,
get_average_rating,
get_batch_details,
get_course_details,
get_field_meta,
get_instructors,
get_lesson_count,
get_lms_route,
has_course_instructor_role,
has_evaluator_role,
@@ -233,14 +232,16 @@ def get_job_details(job: str):
@frappe.whitelist(allow_guest=True)
def get_job_opportunities(filters: dict = None, orFilters: dict = None):
def get_job_opportunities(
filters: dict = None, or_filters: dict = None, start: int = 0, page_length: int = 40
):
if not filters:
filters = {}
jobs = frappe.get_all(
"Job Opportunity",
filters=filters,
or_filters=orFilters,
or_filters=or_filters,
fields=[
"job_title",
"location",
@@ -253,6 +254,8 @@ def get_job_opportunities(filters: dict = None, orFilters: dict = None):
"creation",
"description",
],
start=start,
page_length=page_length,
order_by="creation desc",
)
@@ -345,11 +348,10 @@ def get_evaluator_details(evaluator: str):
@frappe.whitelist()
def get_certified_participants(filters: dict = None, start: int = 0, page_length: int = 100):
def get_certified_participants(filters: dict = None, start: int = 0, page_length: int = 40):
query = get_certification_query(filters)
query = query.orderby("issue_date", order=frappe.qb.desc).offset(start).limit(page_length)
participants = query.run(as_dict=True)
for participant in participants:
details = get_certified_participant_details(participant.member)
participant.update(details)
@@ -362,7 +364,7 @@ def get_certified_participant_details(member: str):
details = frappe.db.get_value(
"User",
member,
["full_name", "user_image", "username", "country", "headline", "open_to"],
["full_name", "user_image", "username", "creation", "headline", "open_to"],
as_dict=1,
)
details["certificate_count"] = count
@@ -375,12 +377,12 @@ def get_certification_query(filters: dict = None):
query = (
frappe.qb.from_(Certificate)
.select(Certificate.member, Certificate.issue_date)
.distinct()
.select(Certificate.member, fn.Max(Certificate.issue_date).as_("issue_date"))
.join(User)
.on(Certificate.member == User.name)
.where(Certificate.published == 1)
.where(User.enabled == 1)
.groupby(Certificate.member)
)
if filters:
@@ -664,7 +666,7 @@ def check_app_permission():
def save_evaluation_details(
member: str,
course: str,
date: str,
date_value: str,
start_time: str,
end_time: str,
status: str,
@@ -680,7 +682,7 @@ def save_evaluation_details(
evaluation = frappe.db.exists("LMS Certificate Evaluation", {"member": member, "course": course})
details = {
"date": date,
"date": date_value,
"start_time": start_time,
"end_time": end_time,
"status": status,
@@ -1004,9 +1006,20 @@ def upsert_chapter(
def extract_package(course: str, title: str, scorm_package: dict):
package = frappe.get_doc("File", scorm_package.name)
zip_path = package.get_full_path()
# check_for_malicious_code(zip_path)
scorm_root = os.path.realpath(frappe.get_site_path("public", "scorm"))
extract_path = frappe.get_site_path("public", "scorm", course, title)
zipfile.ZipFile(zip_path).extractall(extract_path)
if not os.path.realpath(extract_path).startswith(scorm_root + os.sep):
frappe.throw(_("Invalid course or chapter name"))
with zipfile.ZipFile(zip_path, "r") as zf:
dest = os.path.realpath(extract_path)
for name in zf.namelist():
target = os.path.realpath(os.path.join(extract_path, name))
if not target.startswith(dest + os.sep) and target != dest:
frappe.throw(_("Invalid file path in package"))
zf.extractall(extract_path)
return extract_path
@@ -1206,9 +1219,9 @@ def fetch_activity_data(member: str, start_date: str):
def count_dates(data: list, date_count: dict):
for entry in data:
date = format_date(entry.creation, "YYYY-MM-dd")
if date in date_count:
date_count[date] += 1
date_value = format_date(entry.creation, "YYYY-MM-dd")
if date_value in date_count:
date_count[date_value] += 1
def prepare_heatmap_data(start_date: str, number_of_days: int, date_count: dict):
@@ -1219,18 +1232,18 @@ def prepare_heatmap_data(start_date: str, number_of_days: int, date_count: dict)
last_seen_month = None
sorted_dates = sorted(date_count.keys())
for date in sorted_dates:
activity_count = date_count[date]
day_of_week = get_datetime(date).strftime("%a")
current_month = get_datetime(date).strftime("%b")
column_index = get_week_difference(start_date, date)
for date_value in sorted_dates:
activity_count = date_count[date_value]
day_of_week = get_datetime(date_value).strftime("%a")
current_month = get_datetime(date_value).strftime("%b")
column_index = get_week_difference(start_date, date_value)
if 0 <= column_index < week_count:
heatmap_data[day_of_week].append(
{
"date": date,
"date": date_value,
"count": activity_count,
"label": f"{activity_count} activities on {format_date(date, 'dd MMM')}",
"label": f"{activity_count} activities on {format_date(date_value, 'dd MMM')}",
}
)
@@ -1381,9 +1394,9 @@ def cancel_evaluation(evaluation: dict):
for event in events:
info = frappe.db.get_value("Event", event.parent, ["starts_on", "subject"], as_dict=1)
date = str(info.starts_on).split(" ")[0]
date_value = str(info.starts_on).split(" ")[0]
if date == str(evaluation.date.format("YYYY-MM-DD")) and evaluation.member_name in info.subject:
if date_value == str(evaluation.date.format("YYYY-MM-DD")) and evaluation.member_name in info.subject:
communication = frappe.db.get_value(
"Communication",
{"reference_doctype": "Event", "reference_name": event.parent},
@@ -1540,6 +1553,9 @@ def update_meta_info(meta_type: str, route: str, meta_tags: list):
def validate_meta_tags(meta_tags: list):
if not isinstance(meta_tags, list):
frappe.throw(_("Meta tags should be a list."))
for tag in meta_tags:
if tag.get("value"):
tag["value"] = frappe.utils.strip_html_tags(str(tag["value"]))
def create_meta(parent_name: str, tag_properties: dict):
@@ -2260,7 +2276,7 @@ def get_course_programming_exercise_progress(course: str, member: str):
return submissions
def get_assessment_from_lesson(course: str, assessmentType: str):
def get_assessment_from_lesson(course: str, assessment_type: str):
assessments = []
lessons = frappe.get_all("Course Lesson", {"course": course}, ["name", "title", "content"])
@@ -2268,10 +2284,10 @@ def get_assessment_from_lesson(course: str, assessmentType: str):
if lesson.content:
content = json.loads(lesson.content)
for block in content.get("blocks", []):
if block.get("type") == assessmentType:
data_field = "exercise" if assessmentType == "program" else assessmentType
quiz_name = block.get("data", {}).get(data_field)
assessments.append(quiz_name)
if block.get("type") == assessment_type:
data_field = "exercise" if assessment_type == "program" else assessment_type
assessment_name = block.get("data", {}).get(data_field)
assessments.append(assessment_name)
return assessments
@@ -2354,3 +2370,17 @@ def search_users_by_role(txt: str = "", roles: str | list | None = None, page_le
{"value": r.name, "description": r.full_name or r.name, "label": r.full_name or r.name}
for r in results
]
@frappe.whitelist()
def export_course_as_zip(course_name: str):
if not can_modify_course(course_name):
frappe.throw(_("You do not have permission to export this course."), frappe.PermissionError)
export_course_zip(course_name)
@frappe.whitelist()
def import_course_from_zip(zip_file_path: str):
frappe.only_for(["Moderator", "Course Creator"])
return import_course_zip(zip_file_path)

View File

@@ -0,0 +1,774 @@
import json
import os
import re
import secrets
import shutil
import tempfile
import zipfile
from datetime import date, datetime, timedelta
import frappe
from frappe import _
from frappe.utils import escape_html, validate_email_address
from frappe.utils.file_manager import is_safe_path
from lms.lms.utils import create_user as create_lms_user
def export_course_zip(course_name):
course = frappe.get_doc("LMS Course", course_name)
chapters = get_chapters_for_export(course.chapters)
lessons = get_lessons_for_export(course_name)
instructors = get_course_instructors(course)
evaluator = get_course_evaluator(course)
assets = get_course_assets(course, lessons, instructors, evaluator)
assessments, questions, test_cases = get_course_assessments(lessons)
safe_time = frappe.utils.now_datetime().strftime("%Y%m%d_%H%M%S")
zip_filename = f"{course.name}_{safe_time}_{secrets.token_hex(4)}.zip"
create_course_zip(
zip_filename,
course,
chapters,
lessons,
assets,
assessments,
questions,
test_cases,
instructors,
evaluator,
)
def get_chapters_for_export(chapters: list):
chapters_list = []
for row in chapters:
chapter = frappe.get_doc("Course Chapter", row.chapter)
chapters_list.append(chapter)
return chapters_list
def get_lessons_for_export(course_name: str):
lessons = frappe.get_all("Course Lesson", {"course": course_name}, pluck="name")
lessons_list = []
for lesson in lessons:
lesson_doc = frappe.get_doc("Course Lesson", lesson)
lessons_list.append(lesson_doc)
return lessons_list
def get_assessment_from_block(block):
block_type = block.get("type")
data_field = "exercise" if block_type == "program" else block_type
name = block.get("data", {}).get(data_field)
doctype = get_assessment_map().get(block_type)
if frappe.db.exists(doctype, name):
return frappe.get_doc(doctype, name)
return None
def get_quiz_questions(doc):
questions = []
for q in doc.questions:
question_doc = frappe.get_doc("LMS Question", q.question)
questions.append(question_doc.as_dict())
return questions
def get_exercise_test_cases(doc):
test_cases = []
for tc in doc.test_cases:
test_case_doc = frappe.get_doc("LMS Test Case", tc.name)
test_cases.append(test_case_doc.as_dict())
return test_cases
def get_assessments_from_lesson(lesson):
assessments, questions, test_cases = [], [], []
content = json.loads(lesson.content) if lesson.content else {}
for block in content.get("blocks", []):
if block.get("type") not in ("quiz", "assignment", "program"):
continue
doc = get_assessment_from_block(block)
if not doc:
continue
assessments.append(doc.as_dict())
if doc.doctype == "LMS Quiz":
questions.extend(get_quiz_questions(doc))
elif doc.doctype == "LMS Programming Exercise":
test_cases.extend(get_exercise_test_cases(doc))
return assessments, questions, test_cases
def get_course_assessments(lessons):
assessments, questions, test_cases = [], [], []
for lesson in lessons:
lesson_assessments, lesson_questions, lesson_test_cases = get_assessments_from_lesson(lesson)
assessments.extend(lesson_assessments)
questions.extend(lesson_questions)
test_cases.extend(lesson_test_cases)
return assessments, questions, test_cases
def get_course_instructors(course):
users = []
for instructor in course.instructors:
user_info = frappe.db.get_value(
"User",
instructor.instructor,
["name", "full_name", "first_name", "last_name", "email", "user_image"],
as_dict=True,
)
if user_info:
users.append(user_info)
return users
def get_course_evaluator(course):
evaluators = []
if course.evaluator and frappe.db.exists("Course Evaluator", course.evaluator):
evaluator_info = frappe.get_doc("Course Evaluator", course.evaluator)
evaluators.append(evaluator_info)
return evaluators
def get_course_assets(course, lessons, instructors, evaluator):
assets = []
if course.image:
assets.append(course.image)
for lesson in lessons:
content = json.loads(lesson.content) if lesson.content else {}
for block in content.get("blocks", []):
if block.get("type") == "upload":
url = block.get("data", {}).get("file_url")
assets.append(url)
for instructor in instructors:
if instructor.get("user_image"):
assets.append(instructor["user_image"])
if len(evaluator):
assets.append(evaluator[0].user_image)
return assets
def read_asset_content(url):
try:
file_doc = frappe.get_doc("File", {"file_url": url})
file_path = file_doc.get_full_path()
if not is_safe_path(file_path):
return None
with open(file_path, "rb") as f:
return f.read()
except Exception:
frappe.log_error(frappe.get_traceback(), f"Could not read asset: {url}")
return None
def create_course_zip(
zip_filename,
course,
chapters,
lessons,
assets,
assessments,
questions,
test_cases,
instructors,
evaluator,
):
try:
tmp_path = os.path.join(tempfile.gettempdir(), zip_filename)
build_course_zip(
tmp_path,
course,
chapters,
lessons,
assets,
assessments,
questions,
test_cases,
instructors,
evaluator,
)
final_path = move_zip_to_private(tmp_path, zip_filename)
schedule_file_deletion(final_path, delay_seconds=600) # 10 minutes
serve_zip(final_path, zip_filename)
except Exception as e:
frappe.throw(
_("Could not create the course ZIP file. Please try again later. Error: {0}").format(str(e))
)
return None
def build_course_zip(
tmp_path, course, chapters, lessons, assets, assessments, questions, test_cases, instructors, evaluator
):
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_file:
write_course_json(zip_file, course)
write_chapters_json(zip_file, chapters)
write_lessons_json(zip_file, lessons)
write_assessments_json(zip_file, assessments, questions, test_cases)
write_assets(zip_file, assets)
write_instructors_json(zip_file, instructors)
write_evaluator_json(zip_file, evaluator)
def write_course_json(zip_file, course):
zip_file.writestr("course.json", frappe_json_dumps(course.as_dict()))
def write_chapters_json(zip_file, chapters):
for chapter in chapters:
chapter_data = chapter.as_dict()
chapter_json = frappe_json_dumps(chapter_data)
safe_name = sanitize_string(chapter.name)
zip_file.writestr(f"chapters/{safe_name}.json", chapter_json)
def write_lessons_json(zip_file, lessons):
for lesson in lessons:
lesson_data = lesson.as_dict()
lesson_json = frappe_json_dumps(lesson_data)
safe_name = sanitize_string(lesson.name)
zip_file.writestr(f"lessons/{safe_name}.json", lesson_json)
def write_assessments_json(zip_file, assessments, questions, test_cases):
for question in questions:
question_json = frappe_json_dumps(question)
safe_name = sanitize_string(question["name"])
zip_file.writestr(f"assessments/questions/{safe_name}.json", question_json)
for test_case in test_cases:
test_case_json = frappe_json_dumps(test_case)
safe_name = sanitize_string(test_case["name"])
zip_file.writestr(f"assessments/test_cases/{safe_name}.json", test_case_json)
for assessment in assessments:
assessment_json = frappe_json_dumps(assessment)
doctype = "_".join(assessment["doctype"].lower().split(" "))
safe_name = "_".join(sanitize_string(assessment["name"]).split(" "))
zip_file.writestr(f"assessments/{doctype}_{safe_name}.json", assessment_json)
def write_assets(zip_file, assets):
assets = list(set(assets))
for asset in assets:
real_path = frappe.get_site_path(asset.lstrip("/"))
if not asset or not isinstance(asset, str) or not is_safe_path(real_path):
continue
file_doc = frappe.get_doc("File", {"file_url": asset})
file_path = os.path.abspath(file_doc.get_full_path())
safe_filename = sanitize_string(os.path.basename(asset))
zip_file.write(file_path, f"assets/{safe_filename}")
def move_zip_to_private(tmp_path, zip_filename):
final_path = os.path.join(frappe.get_site_path("private", "files"), zip_filename)
shutil.move(tmp_path, final_path)
return final_path
def write_instructors_json(zip_file, instructors):
instructors_json = frappe_json_dumps(instructors)
zip_file.writestr("instructors.json", instructors_json)
def write_evaluator_json(zip_file, evaluator):
if not len(evaluator):
return
evaluator_json = frappe_json_dumps(evaluator[0].as_dict())
zip_file.writestr("evaluator.json", evaluator_json)
def serve_zip(final_path, zip_filename):
if not os.path.exists(final_path) or not os.path.isfile(final_path):
frappe.throw(_("File not found"))
safe_filename = sanitize_string(zip_filename)
try:
with open(final_path, "rb") as f:
frappe.local.response.filename = safe_filename
frappe.local.response.filecontent = f.read()
frappe.local.response.type = "download"
frappe.local.response.content_type = "application/zip"
except Exception as e:
frappe.log_error(f"Error serving ZIP file: {str(e)}")
frappe.throw(_("Error downloading file"))
def schedule_file_deletion(file_path, delay_seconds=600):
frappe.enqueue(
delete_file,
file_path=file_path,
queue="long",
timeout=delay_seconds,
at_front=False,
enqueue_after_commit=True,
)
def delete_file(file_path):
try:
if os.path.exists(file_path):
os.remove(file_path)
except Exception as e:
frappe.log_error(f"Error deleting exported file {file_path}: {e}")
def frappe_json_dumps(data):
def default(obj):
try:
if isinstance(obj, (datetime | date | timedelta)):
return str(obj)
except Exception as e:
frappe.log_error(f"Error serializing object {obj}: {e}")
return json.dumps(data, indent=4, default=default)
def import_course_zip(zip_file_path):
zip_file_path = zip_file_path.lstrip("/")
actual_path = frappe.get_site_path(zip_file_path)
validate_zip_file(actual_path)
with zipfile.ZipFile(actual_path, "r") as zip_file:
course_data = read_json_from_zip(zip_file, "course.json")
if not course_data:
frappe.throw(_("Invalid course ZIP: Missing course.json"))
create_assets(zip_file)
create_user_for_instructors(zip_file)
create_evaluator(zip_file)
course_doc = create_course_doc(course_data)
chapter_docs = create_chapter_docs(zip_file, course_doc.name)
create_assessment_docs(zip_file)
create_lesson_docs(zip_file, course_doc.name, chapter_docs)
save_course_structure(zip_file, course_doc, chapter_docs)
return course_doc.name
def read_json_from_zip(zip_file, filename):
try:
with zip_file.open(filename) as f:
return json.load(f)
except Exception as e:
frappe.log_error(f"Error reading {filename} from ZIP: {e}")
return None
def create_user_for_instructors(zip_file):
instructors = read_json_from_zip(zip_file, "instructors.json")
if not instructors:
return
for instructor in instructors:
if not frappe.db.exists("User", instructor["email"]):
create_user(instructor)
def sanitize_string(
value,
allow_spaces=True,
max_length=None,
replacement_char=None,
escape_html_content=True,
strip_whitespace=True,
):
"""
Unified function to sanitize strings for various use cases.
Args:
value: String to sanitize
allow_spaces: Whether to allow spaces in the output (True for names, False for filenames)
max_length: Maximum length to truncate to (None for no limit)
replacement_char: Character to replace invalid chars with (None to remove them)
escape_html_content: Whether to escape HTML entities
strip_whitespace: Whether to strip leading/trailing whitespace
Returns:
Sanitized string
"""
if not value:
return value
if strip_whitespace:
value = value.strip()
if max_length:
value = value[:max_length]
if escape_html_content:
value = escape_html(value)
if allow_spaces:
invalid_pattern = r"[^a-zA-Z0-9\s\-\.]"
valid_pattern = r"^[a-zA-Z0-9\s\-\.]+$"
else:
invalid_pattern = r"[^a-zA-Z0-9_\-\.]"
valid_pattern = r"^[a-zA-Z0-9_\-\.]+$"
if replacement_char is None:
if not re.match(valid_pattern, value):
value = re.sub(invalid_pattern, "", value)
else:
value = re.sub(invalid_pattern, replacement_char, value)
return value
def validate_user_email(user):
if not user.get("email") or not validate_email_address(user["email"]):
frappe.throw(f"Invalid email for user creation: {user.get('email')}")
def get_user_names(user):
first_name = sanitize_string(user.get("first_name", ""), max_length=50)
last_name = sanitize_string(user.get("last_name", ""), max_length=50)
full_name = sanitize_string(user.get("full_name", ""), max_length=100)
parts = full_name.split() if full_name else []
return (
first_name or (parts[0] if parts else "Imported"),
last_name or (" ".join(parts[1:]) if len(parts) > 1 else None),
full_name,
)
def create_user(user):
first_name, last_name, full_name = get_user_names(user)
user_doc = create_lms_user(
email=user["email"],
first_name=first_name,
last_name=last_name,
full_name=full_name,
user_image=user.get("user_image"),
roles=["Course Creator"],
)
return user_doc
def create_evaluator(zip_file):
evaluator_data = read_json_from_zip(zip_file, "evaluator.json")
if not evaluator_data:
return
if not evaluator_data.get("evaluator") or not validate_email_address(evaluator_data.get("evaluator", "")):
frappe.log_error(f"Invalid evaluator data: {evaluator_data}")
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"]):
evaluator_doc = frappe.new_doc("Course Evaluator")
evaluator_doc.update(evaluator_data)
evaluator_doc.insert(ignore_permissions=True)
def get_course_fields():
return [
"title",
"tags",
"image",
"video_link",
"card_gradient",
"short_introduction",
"description",
"published",
"upcoming",
"featured",
"disable_self_learning",
"published_on",
"category",
"evaluator",
"timezone",
"paid_course",
"paid_certificate",
"course_price",
"currency",
"amount_usd",
"enable_certification",
]
def add_data_to_course(course_doc, course_data):
for field in get_course_fields():
if field in course_data:
course_doc.set(field, course_data[field])
def add_instructors_to_course(course_doc, course_data):
instructors = course_data.get("instructors", [])
for instructor in instructors:
course_doc.append("instructors", {"instructor": instructor["instructor"]})
def verify_category(category_name):
if category_name and not frappe.db.exists("LMS Category", category_name):
category = frappe.new_doc("LMS Category")
category.category = category_name
category.insert(ignore_permissions=True)
def create_course_doc(course_data):
course_doc = frappe.new_doc("LMS Course")
add_instructors_to_course(course_doc, course_data)
verify_category(course_data.get("category"))
course_data.pop("instructors", None)
course_data.pop("chapters", None)
add_data_to_course(course_doc, course_data)
course_doc.insert(ignore_permissions=True)
return course_doc
def exclude_meta_fields(data):
meta_fields = ["name", "owner", "creation", "created_by", "modified", "modified_by", "docstatus"]
return {k: v for k, v in data.items() if k not in meta_fields}
def create_chapter_docs(zip_file, course_name):
chapter_docs = []
for file in zip_file.namelist():
if file.startswith("chapters/") and file.endswith(".json"):
chapter_data = read_json_from_zip(zip_file, file)
chapter_data = exclude_meta_fields(chapter_data)
if chapter_data:
chapter_doc = frappe.new_doc("Course Chapter")
chapter_data.pop("lessons", None)
chapter_doc.update(chapter_data)
chapter_doc.course = course_name
chapter_doc.insert(ignore_permissions=True)
chapter_docs.append(chapter_doc)
return chapter_docs
def get_chapter_name_for_lesson(zip_file, lesson_data, chapter_docs):
for file in zip_file.namelist():
if file.startswith("chapters/") and file.endswith(".json"):
chapter_data = read_json_from_zip(zip_file, file)
if chapter_data.get("name") == lesson_data.get("chapter"):
title = chapter_data.get("title")
chapter_doc = next((c for c in chapter_docs if c.title == title), None)
if chapter_doc:
return chapter_doc.name
return None
def get_assessment_map():
return {"quiz": "LMS Quiz", "assignment": "LMS Assignment", "program": "LMS Programming Exercise"}
def get_assessment_title(zip_file, assessment_name, assessment_type):
assessment_map = get_assessment_map()
doctype = "_".join(assessment_map.get(assessment_type).lower().split(" "))
assessment_name = "_".join(assessment_name.split(" "))
file_name = f"assessments/{doctype}_{assessment_name}.json"
try:
with zip_file.open(file_name) as f:
assessment_data = json.load(f)
return assessment_data.get("title")
except Exception as e:
frappe.log_error(f"Error reading {file_name} from ZIP: {e}")
return None
def replace_assessment_names(zip_file, content):
assessment_types = ["quiz", "assignment", "program"]
content = json.loads(content)
for block in content.get("blocks", []):
if block.get("type") in assessment_types:
data_field = "exercise" if block.get("type") == "program" else block.get("type")
assessment_name = block.get("data", {}).get(data_field)
assessment_title = get_assessment_title(zip_file, assessment_name, block.get("type"))
doctype = get_assessment_map().get(block.get("type"))
current_assessment_name = frappe.db.get_value(doctype, {"title": assessment_title}, "name")
if current_assessment_name:
block["data"][data_field] = current_assessment_name
return json.dumps(content)
def replace_assets(content):
content = json.loads(content)
for block in content.get("blocks", []):
if block.get("type") == "upload":
asset_url = block.get("data", {}).get("file_url")
if asset_url:
asset_name = asset_url.split("/")[-1]
current_asset_url = frappe.db.get_value("LMS Asset", {"file_name": asset_name}, "file_url")
if current_asset_url:
block["data"]["url"] = current_asset_url
def replace_values_in_content(zip_file, content):
return replace_assessment_names(zip_file, content)
# replace_assets(content)
def create_lesson_docs(zip_file, course_name, chapter_docs):
lesson_docs = []
for file in zip_file.namelist():
if file.startswith("lessons/") and file.endswith(".json"):
lesson_data = read_json_from_zip(zip_file, file)
lesson_data = exclude_meta_fields(lesson_data)
if lesson_data:
lesson_doc = frappe.new_doc("Course Lesson")
lesson_doc.update(lesson_data)
lesson_doc.course = course_name
lesson_doc.chapter = get_chapter_name_for_lesson(zip_file, lesson_data, chapter_docs)
lesson_doc.content = (
replace_values_in_content(zip_file, lesson_doc.content) if lesson_doc.content else None
)
lesson_doc.insert(ignore_permissions=True)
lesson_docs.append(lesson_doc)
return lesson_docs
def create_question_doc(zip_file, file):
question_data = read_json_from_zip(zip_file, file)
if question_data:
doc = frappe.new_doc("LMS Question")
doc.update(question_data)
doc.insert(ignore_permissions=True)
def create_test_case_doc(zip_file, file):
test_case_data = read_json_from_zip(zip_file, file)
if test_case_data:
doc = frappe.new_doc("LMS Test Case")
doc.update(test_case_data)
doc.insert(ignore_permissions=True)
def add_questions_to_quiz(quiz_doc, questions):
for question in questions:
question_detail = question["question_detail"]
question_name = frappe.db.get_value("LMS Question", {"question": question_detail}, "name")
if question_name:
quiz_doc.append("questions", {"question": question_name})
def create_supporting_docs(zip_file):
for file in zip_file.namelist():
if file.startswith("assessments/questions/") and file.endswith(".json"):
create_question_doc(zip_file, file)
elif file.startswith("assessments/test_cases/") and file.endswith(".json"):
create_test_case_doc(zip_file, file)
def is_assessment_file(file):
return (
file.startswith("assessments/")
and file.endswith(".json")
and not file.startswith("assessments/questions/")
and not file.startswith("assessments/test_cases/")
)
def build_assessment_doc(assessment_data):
doctype = assessment_data.get("doctype")
if doctype not in ("LMS Quiz", "LMS Assignment", "LMS Programming Exercise"):
return
if frappe.db.exists(doctype, assessment_data.get("name")):
return
questions = assessment_data.pop("questions", [])
test_cases = assessment_data.pop("test_cases", [])
doc = frappe.new_doc(doctype)
doc.update(assessment_data)
if doctype == "LMS Quiz":
add_questions_to_quiz(doc, questions)
elif doctype == "LMS Programming Exercise":
for row in test_cases:
doc.append("test_cases", {"input": row["input"], "expected_output": row["expected_output"]})
doc.insert(ignore_permissions=True)
def create_main_assessment_docs(zip_file):
for file in zip_file.namelist():
if not is_assessment_file(file):
continue
assessment_data = read_json_from_zip(zip_file, file)
if not assessment_data:
continue
assessment_data.pop("lesson", None)
assessment_data.pop("course", None)
build_assessment_doc(assessment_data)
def create_assessment_docs(zip_file):
create_supporting_docs(zip_file)
create_main_assessment_docs(zip_file)
def create_asset_doc(asset_name, content):
if frappe.db.exists("File", {"file_name": asset_name}):
return
asset_doc = frappe.new_doc("File")
asset_doc.file_name = asset_name
asset_doc.content = content
asset_doc.insert()
def process_asset_file(zip_file, file):
if not is_safe_path(file):
return
with zip_file.open(file) as f:
create_asset_doc(file.split("/")[-1], f.read())
def create_assets(zip_file):
for file in zip_file.namelist():
if not file.startswith("assets/") or file.endswith("/"):
continue
try:
process_asset_file(zip_file, file)
except Exception as e:
frappe.log_error(f"Error processing asset {file}: {e}")
def get_lesson_title(zip_file, lesson_name):
for file in zip_file.namelist():
if file.startswith("lessons/") and file.endswith(".json"):
lesson_data = read_json_from_zip(zip_file, file)
if lesson_data.get("name") == lesson_name:
return lesson_data.get("title")
return None
def add_lessons_to_chapters(zip_file, course_name, chapter_docs):
for file in zip_file.namelist():
if file.startswith("chapters/") and file.endswith(".json"):
chapter_data = read_json_from_zip(zip_file, file)
chapter_doc = next((c for c in chapter_docs if c.title == chapter_data.get("title")), None)
if not chapter_doc:
continue
for lesson in chapter_data.get("lessons", []):
lesson_title = get_lesson_title(zip_file, lesson["lesson"])
lesson_name = frappe.db.get_value(
"Course Lesson", {"title": lesson_title, "course": course_name}, "name"
)
if lesson_name:
chapter_doc.append("lessons", {"lesson": lesson_name})
chapter_doc.save(ignore_permissions=True)
def add_chapter_to_course(course_doc, chapter_docs):
course_doc.reload()
for chapter_doc in chapter_docs:
course_doc.append("chapters", {"chapter": chapter_doc.name})
course_doc.save(ignore_permissions=True)
def save_course_structure(zip_file, course_doc, chapter_docs):
add_chapter_to_course(course_doc, chapter_docs)
add_lessons_to_chapters(zip_file, course_doc.name, chapter_docs)
def validate_zip_file(zip_file_path):
if not os.path.exists(zip_file_path) or not zipfile.is_zipfile(zip_file_path):
frappe.throw(_("Invalid ZIP file"))
if not is_safe_path(zip_file_path):
frappe.throw(_("Unsafe file path detected"))

View File

@@ -10,9 +10,9 @@
"field_order": [
"title",
"include_in_preview",
"is_scorm_package",
"column_break_4",
"chapter",
"is_scorm_package",
"course",
"section_break_11",
"content",
@@ -160,11 +160,11 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-02-20 13:49:25.599827",
"modified_by": "Administrator",
"modified": "2026-04-01 12:21:25.050340",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "Course Lesson",
"naming_rule": "Expression",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{

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

@@ -224,9 +224,7 @@ def update_course_statistics():
for course in courses:
lessons = get_lesson_count(course.name)
enrollments = frappe.db.count("LMS Enrollment", {"course": course.name, "member_type": "Student"})
avg_rating = get_average_rating(course.name) or 0
avg_rating = flt(avg_rating, frappe.get_system_settings("float_precision") or 3)

View File

@@ -50,6 +50,11 @@ class LMSEnrollment(Document):
)
if self.enrollment_from_batch:
if not frappe.db.exists(
"Batch Course", {"parent": self.enrollment_from_batch, "course": self.course}
):
frappe.throw(_("This batch is not associated with this course."))
if frappe.db.exists(
"LMS Batch Enrollment", {"batch": self.enrollment_from_batch, "member": self.member}
):

View File

@@ -34,8 +34,7 @@
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title",
"reqd": 1,
"unique": 1
"reqd": 1
},
{
"fieldname": "questions",
@@ -159,7 +158,7 @@
"link_fieldname": "quiz"
}
],
"modified": "2026-03-25 20:22:22.124828",
"modified": "2026-04-01 16:56:28.727089",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Quiz",

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,
country: str,
):
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

@@ -1,6 +1,17 @@
import glob
import os
import re
import zipfile
import frappe
from lms.lms.api import get_certified_participants, get_course_assessment_progress
from lms.lms.api import (
export_course_as_zip,
get_certified_participants,
get_course_assessment_progress,
import_course_from_zip,
)
from lms.lms.course_import_export import sanitize_string
from lms.lms.test_helpers import BaseTestUtils
@@ -83,3 +94,85 @@ class TestLMSAPI(BaseTestUtils):
)
self.assertEqual(result.is_correct, 1 if index % 2 == 0 else 0)
self.assertEqual(result.marks, 5 if index % 2 == 0 else 0)
def test_export_course_as_zip(self):
latest_file = self.get_latest_zip_file()
self.assertTrue(latest_file)
self.assertTrue(latest_file.endswith(".zip"))
expected_name_pattern = re.escape(self.course.name) + r"_\d{8}_\d{6}_[a-f0-9]{8}\.zip"
self.assertRegex(latest_file, expected_name_pattern)
with zipfile.ZipFile(latest_file, "r") as zip_ref:
expected_files = [
"course.json",
"instructors.json",
]
for expected_file in expected_files:
self.assertIn(expected_file, zip_ref.namelist())
chapter_files = [
f for f in zip_ref.namelist() if f.startswith("chapters/") and f.endswith(".json")
]
self.assertEqual(len(chapter_files), 3)
lesson_files = [f for f in zip_ref.namelist() if f.startswith("lessons/") and f.endswith(".json")]
self.assertEqual(len(lesson_files), 12)
assessment_files = [
f
for f in zip_ref.namelist()
if f.startswith("assessments/") and f.endswith(".json") and len(f.split("/")) == 2
]
self.assertEqual(len(assessment_files), 3)
def get_latest_zip_file(self):
export_course_as_zip(self.course.name)
site_path = frappe.get_site_path("private", "files")
zip_files = glob.glob(os.path.join(site_path, f"{self.course.name}_*.zip"))
latest_file = max(zip_files, key=os.path.getctime) if zip_files else None
return latest_file
def test_import_course_from_zip(self):
imported_course = self.get_imported_course()
self.assertEqual(imported_course.title, self.course.title)
self.assertEqual(imported_course.category, self.course.category)
# self.assertEqual(imported_course.lessons, self.course.lessons)
self.assertEqual(len(imported_course.instructors), len(self.course.instructors))
self.assertEqual(imported_course.instructors[0].instructor, self.course.instructors[0].instructor)
imported_first_chapter = frappe.get_doc("Course Chapter", self.course.chapters[0].chapter)
original_first_chapter = frappe.get_doc("Course Chapter", self.course.chapters[0].chapter)
self.assertEqual(imported_first_chapter.title, original_first_chapter.title)
imported_first_lesson = frappe.get_doc("Course Lesson", imported_first_chapter.lessons[0].lesson)
original_first_lesson = frappe.get_doc("Course Lesson", original_first_chapter.lessons[0].lesson)
self.assertEqual(imported_first_lesson.title, original_first_lesson.title)
self.assertEqual(imported_first_lesson.content, original_first_lesson.content)
self.cleanup_imported_course(imported_course.name)
def get_imported_course(self):
latest_file = self.get_latest_zip_file()
self.assertTrue(latest_file)
zip_file_path = f"/{'/'.join(latest_file.split('/')[2:])}"
imported_course_name = import_course_from_zip(zip_file_path)
imported_course = frappe.get_doc("LMS Course", imported_course_name)
return imported_course
def cleanup_imported_course(self, course_name):
self.cleanup_items.append(("LMS Course", course_name))
self.cleanup_imported_assessment("LMS Quiz", self.quiz)
self.cleanup_imported_assessment("LMS Assignment", self.assignment)
self.cleanup_imported_assessment("LMS Programming Exercise", self.programming_exercise)
def cleanup_imported_assessment(self, doctype, doc):
imported_assessment = frappe.db.get_value(
doctype, {"title": doc.title, "name": ["!=", doc.name]}, "name"
)
if imported_assessment:
self.cleanup_items.append((doctype, imported_assessment))
def test_sanitize_string_filename_behavior(self):
result = sanitize_string(
"my file@name!.txt", allow_spaces=False, replacement_char="_", escape_html_content=False
)
self.assertEqual(result, "my_file_name_.txt")
def test_sanitize_string_name_field_behavior(self):
result = sanitize_string(
"John#Doe$", allow_spaces=True, max_length=50, replacement_char=None, escape_html_content=True
)
self.assertEqual(result, "JohnDoe")

View File

@@ -7,6 +7,7 @@ from frappe.utils import getdate, to_timedelta
from lms.lms.doctype.lms_certificate.lms_certificate import is_certified
from lms.lms.test_helpers import BaseTestUtils
from lms.lms.utils import (
create_user,
get_average_rating,
get_batch_details,
get_chapters,
@@ -157,3 +158,24 @@ class TestLMSUtils(BaseTestUtils):
self.assertEqual(batch_details.evaluation_end_date, getdate(self.batch.evaluation_end_date))
self.assertEqual(len(batch_details.instructors), len(self.batch.instructors))
self.assertEqual(len(batch_details.students), 2)
def test_create_user(self):
user = create_user(
email="testuser@example.com", first_name="Test", last_name="User", roles=["LMS Student"]
)
self.assertEqual(user.email, "testuser@example.com")
self.assertEqual(user.first_name, "Test")
self.assertEqual(user.last_name, "User")
self.assertEqual(user.full_name, "Test User")
self.assertIn("LMS Student", [role.role for role in user.roles])
self.cleanup_items.append(("User", user.name))
def test_create_user_with_full_name(self):
user = create_user(
email="fullnameuser@example.com", full_name="John Michael Doe", roles=["Course Creator"]
)
self.assertEqual(user.first_name, "John")
self.assertEqual(user.last_name, "Michael Doe")
self.assertEqual(user.full_name, "John Michael Doe")
self.assertIn("Course Creator", [role.role for role in user.roles])
self.cleanup_items.append(("User", user.name))

View File

@@ -1,7 +1,7 @@
import frappe
from frappe import _
from frappe.model.naming import append_number_if_name_exists
from frappe.utils import escape_html, random_string
from frappe.utils import cint, escape_html, random_string
from frappe.website.utils import cleanup_page_name, is_signup_disabled
from lms.lms.utils import get_country_code, get_lms_route
@@ -23,7 +23,7 @@ def after_insert(doc, method):
doc.add_roles("LMS Student")
@frappe.whitelist(allow_guest=True)
@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
def sign_up(email: str, full_name: str, verify_terms: bool, user_category: str):
if is_signup_disabled():
frappe.throw(_("Sign Up is disabled"), _("Not Allowed"))
@@ -35,7 +35,9 @@ def sign_up(email: str, full_name: str, verify_terms: bool, user_category: str):
else:
return 0, _("Registered but disabled")
else:
if frappe.db.get_creation_count("User", 60) > 300:
max_signups_allowed_per_hour = cint(frappe.get_system_settings("max_signups_allowed_per_hour") or 300)
users_created_past_hour = frappe.db.get_creation_count("User", 60)
if users_created_past_hour >= max_signups_allowed_per_hour:
frappe.respond_as_web_page(
_("Temporarily Disabled"),
_(

View File

@@ -23,6 +23,7 @@ from frappe.utils import (
nowtime,
pretty_date,
rounded,
validate_email_address,
)
from pypika import Case
from pypika import functions as fn
@@ -86,6 +87,49 @@ def generate_slug(title: str, doctype: str):
return slugify(title, used_slugs=slugs)
def process_user_names(first_name, last_name, full_name):
if not first_name and full_name:
name_parts = full_name.split()
first_name = name_parts[0] if name_parts else "User"
last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else ""
if not full_name:
full_name = f"{first_name} {last_name or ''}".strip()
return first_name, last_name or "", full_name
def create_user_document(email, first_name, last_name, full_name, user_image=None, roles=None):
user_doc = frappe.new_doc("User")
user_doc.email = email
user_doc.first_name = first_name
user_doc.last_name = last_name
user_doc.full_name = full_name
user_doc.user_image = user_image
user_doc.send_welcome_email = False
if not roles:
roles = ["LMS Student"]
for role in roles:
user_doc.append("roles", {"role": role})
user_doc.insert()
return user_doc
def create_user(email, first_name=None, last_name=None, full_name=None, user_image=None, roles=None):
validate_email_address(email, True)
print(email)
print(frappe.db.exists("User", email))
existing_user = frappe.db.exists("User", email)
print("existing_user", existing_user)
if existing_user:
print("User already exists")
return frappe.get_doc("User", email)
first_name, last_name, full_name = process_user_names(first_name, last_name, full_name)
user_doc = create_user_document(email, first_name, last_name, full_name, user_image, roles)
return user_doc
def get_membership(course: str, member: str = None):
if not member:
member = frappe.session.user
@@ -549,7 +593,6 @@ def get_lesson_count(course: str) -> int:
chapters = frappe.get_all("Chapter Reference", {"parent": course}, ["chapter"])
for chapter in chapters:
lesson_count += frappe.db.count("Lesson Reference", {"parent": chapter.chapter})
return lesson_count
@@ -1864,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):

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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"
}