mirror of
https://github.com/frappe/lms.git
synced 2026-04-19 22:52:29 +03:00
Merge remote-tracking branch 'upstream/develop' into feat/rtl
This commit is contained in:
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@@ -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 }}"
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 1%
|
||||
|
||||
ignore:
|
||||
- "**/test_helper.py"
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
? { sections: activeTab.sections }
|
||||
: {}),
|
||||
...(activeTab.label == 'Members' ||
|
||||
activeTab.label == 'Evaluators' ||
|
||||
activeTab.label == 'Transactions'
|
||||
? { 'onUpdate:show': (val) => (show = val), show }
|
||||
: {}),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
201
frontend/src/pages/Courses/CourseImportModal.vue
Normal file
201
frontend/src/pages/Courses/CourseImportModal.vue
Normal 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>
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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}/`)
|
||||
|
||||
@@ -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'>
|
||||
|
||||
16
frontend/src/utils/theme.ts
Normal file
16
frontend/src/utils/theme.ts
Normal 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 }
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
774
lms/lms/course_import_export.py
Normal file
774
lms/lms/course_import_export.py
Normal 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"))
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}
|
||||
):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"),
|
||||
_(
|
||||
|
||||
@@ -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):
|
||||
|
||||
391
lms/locale/ar.po
391
lms/locale/ar.po
File diff suppressed because it is too large
Load Diff
393
lms/locale/bs.po
393
lms/locale/bs.po
File diff suppressed because it is too large
Load Diff
391
lms/locale/cs.po
391
lms/locale/cs.po
File diff suppressed because it is too large
Load Diff
391
lms/locale/da.po
391
lms/locale/da.po
File diff suppressed because it is too large
Load Diff
391
lms/locale/de.po
391
lms/locale/de.po
File diff suppressed because it is too large
Load Diff
393
lms/locale/eo.po
393
lms/locale/eo.po
File diff suppressed because it is too large
Load Diff
391
lms/locale/es.po
391
lms/locale/es.po
File diff suppressed because it is too large
Load Diff
393
lms/locale/fa.po
393
lms/locale/fa.po
File diff suppressed because it is too large
Load Diff
391
lms/locale/fr.po
391
lms/locale/fr.po
File diff suppressed because it is too large
Load Diff
393
lms/locale/hr.po
393
lms/locale/hr.po
File diff suppressed because it is too large
Load Diff
391
lms/locale/hu.po
391
lms/locale/hu.po
File diff suppressed because it is too large
Load Diff
391
lms/locale/id.po
391
lms/locale/id.po
File diff suppressed because it is too large
Load Diff
393
lms/locale/it.po
393
lms/locale/it.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
391
lms/locale/my.po
391
lms/locale/my.po
File diff suppressed because it is too large
Load Diff
391
lms/locale/nb.po
391
lms/locale/nb.po
File diff suppressed because it is too large
Load Diff
393
lms/locale/nl.po
393
lms/locale/nl.po
File diff suppressed because it is too large
Load Diff
391
lms/locale/pl.po
391
lms/locale/pl.po
File diff suppressed because it is too large
Load Diff
391
lms/locale/pt.po
391
lms/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
393
lms/locale/ru.po
393
lms/locale/ru.po
File diff suppressed because it is too large
Load Diff
393
lms/locale/sl.po
393
lms/locale/sl.po
File diff suppressed because it is too large
Load Diff
393
lms/locale/sr.po
393
lms/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
393
lms/locale/sv.po
393
lms/locale/sv.po
File diff suppressed because it is too large
Load Diff
391
lms/locale/th.po
391
lms/locale/th.po
File diff suppressed because it is too large
Load Diff
391
lms/locale/tr.po
391
lms/locale/tr.po
File diff suppressed because it is too large
Load Diff
391
lms/locale/vi.po
391
lms/locale/vi.po
File diff suppressed because it is too large
Load Diff
393
lms/locale/zh.po
393
lms/locale/zh.po
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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.")}}
|
||||
|
||||
124
lms/workspace_sidebar/learning.json
Normal file
124
lms/workspace_sidebar/learning.json
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user