Compare commits

...

118 Commits

Author SHA1 Message Date
Frappe PR Bot
b6422d1046 chore(release): Bumped to Version 2.5.0 2024-09-18 04:46:24 +00:00
Jannat Patel
7196bbe221 Merge pull request #1019 from pateljannat/issues-34
fix: misc issues
2024-09-18 09:03:33 +05:30
Jannat Patel
bed16c3726 fix: condition to show course addition button 2024-09-18 08:56:35 +05:30
Jannat Patel
d18ca232e3 fix: misc issues 2024-09-18 08:43:16 +05:30
Jannat Patel
d1200d0fa9 fix: profile page empty bio error 2024-09-17 11:48:49 +05:30
Jannat Patel
d1c88b306f Merge pull request #1018 from pateljannat/batch-quiz
feat: quiz page
2024-09-17 10:42:34 +05:30
Jannat Patel
7f2723f9cb Merge pull request #1014 from pateljannat/batch-welcome-email-fix
fix: batch confirmation email trigger
2024-09-17 10:30:00 +05:30
Jannat Patel
8df4bef71a feat: quiz page 2024-09-17 10:25:59 +05:30
Jannat Patel
aa87622606 Merge pull request #1017 from pateljannat/refactor-lesson-editor
refactor: adding quiz and uploading content to a lesson
2024-09-16 19:19:42 +05:30
Jannat Patel
b91339fe28 fix: removed unnecessary files 2024-09-16 18:50:06 +05:30
Jannat Patel
17d4973ab8 test: fixed course creation 2024-09-16 18:41:25 +05:30
Jannat Patel
3c12548420 Merge pull request #1016 from frappe/pot_develop_2024-09-13
chore: update POT file
2024-09-16 16:50:16 +05:30
Jannat Patel
20c10f1645 feat: help guide videos 2024-09-16 16:49:17 +05:30
Jannat Patel
a7843e0e3a refactor: uploading content in lesson 2024-09-16 10:33:16 +05:30
frappe-pr-bot
169ea4385f chore: update POT file 2024-09-13 16:04:09 +00:00
Jannat Patel
9549f3a3ed Merge pull request #1015 from pateljannat/course-completion-funnel
chore: analytics for course completion
2024-09-13 11:37:22 +05:30
Jannat Patel
ba66c2549f chore: renamed lesson_progress to course_progress for analytics 2024-09-13 11:21:59 +05:30
Jannat Patel
76c3e630cc chore: analytics for course completion 2024-09-13 11:18:17 +05:30
Jannat Patel
7a0b952638 style: trigger condition 2024-09-13 09:57:41 +05:30
Jannat Patel
5966a3edad fix: batch confirmation email trigger 2024-09-13 09:57:06 +05:30
Jannat Patel
d44c7cd9fc Merge pull request #1010 from pateljannat/evaluation-calendar
feat: Evaluation and Certification from Learning Portal
2024-09-12 16:48:51 +05:30
Jannat Patel
46553987ac fix: certification tab title 2024-09-12 16:22:43 +05:30
Jannat Patel
45725f1f6e chore: bumped up frappe-ui 2024-09-12 14:31:55 +05:30
Jannat Patel
58369ba65e fix: evaluator info and other styles 2024-09-11 19:51:14 +05:30
Jannat Patel
5ce67dda2e Merge pull request #1002 from prachi8848/cusrsor
feat: added a cusrsor
2024-09-11 15:05:48 +05:30
Jannat Patel
237ff8db07 Merge pull request #1008 from frappe/pot_develop_2024-09-06
chore: update POT file
2024-09-10 21:02:55 +05:30
Jannat Patel
7da608ed44 feat: certification details and form 2024-09-10 21:00:49 +05:30
Jannat Patel
60f2e86b42 feat: evaluation feedback record 2024-09-09 20:05:08 +05:30
frappe-pr-bot
b5e67a25d2 chore: update POT file 2024-09-06 16:03:59 +00:00
Jannat Patel
9d2ef4929c Merge pull request #1007 from pateljannat/fix-notification
fix: certificate request creation email
2024-09-06 10:49:52 +05:30
Jannat Patel
050084e552 style: fix formatting 2024-09-06 10:40:25 +05:30
Jannat Patel
86e9739218 fix: certificate request creation email 2024-09-06 10:19:25 +05:30
Jannat Patel
bd94890da7 Merge pull request #1005 from pateljannat/minor-ui-fix
fix: member-list ui
2024-09-05 20:36:41 +05:30
Jannat Patel
965f6adb90 style: fixed formatting 2024-09-05 20:31:22 +05:30
Jannat Patel
4979569cf3 fix: member-list ui 2024-09-05 19:59:44 +05:30
Frappe PR Bot
5c21a0532a chore(release): Bumped to Version 2.4.0 2024-09-04 05:00:25 +00:00
sonali8848
a2025c0571 feat: added a cusrsor 2024-09-02 09:41:22 +00:00
Jannat Patel
e07aae3fb0 Merge pull request #997 from pateljannat/issues-33
fix: slides rendering issue
2024-08-29 19:26:08 +05:30
Jannat Patel
65d628ffc0 fix: slides rendering issue 2024-08-29 11:10:43 +05:30
Jannat Patel
bf290bbf0a Merge pull request #994 from akhilnarang/fix-user-creation
fix(overrides): call parent's `after_insert()` as well
2024-08-27 14:56:11 +05:30
Akhil Narang
3c9059025b fix(overrides): call parent's after_insert() as well
Signed-off-by: Akhil Narang <me@akhilnarang.dev>
2024-08-27 14:08:35 +05:30
Jannat Patel
4b0413720b Merge pull request #993 from pateljannat/quiz-submission-issue
fix: quiz submission report issue
2024-08-27 11:58:14 +05:30
Jannat Patel
f8b4ff4bd3 fix: quiz submission report issue 2024-08-27 10:46:06 +05:30
Jannat Patel
3b8ff171f4 Merge pull request #989 from frappe/pot_develop_2024-08-23
chore: update POT file
2024-08-26 14:45:59 +05:30
frappe-pr-bot
dec270a10b chore: update POT file 2024-08-23 16:04:00 +00:00
Jannat Patel
152a339c4e Merge pull request #986 from pateljannat/app-switcher
feat: App switcher
2024-08-23 12:40:16 +05:30
Jannat Patel
395fe700e0 fix: removed switch to desk 2024-08-23 12:22:11 +05:30
Jannat Patel
ec25e895dc feat: app switcher 2024-08-23 12:21:22 +05:30
Frappe PR Bot
e02e4c7ab4 chore(release): Bumped to Version 2.3.0 2024-08-21 05:20:24 +00:00
Jannat Patel
e69cc9af1a Merge pull request #980 from pateljannat/member-addition
feat: Add users from the portal
2024-08-19 14:11:59 +05:30
Jannat Patel
98b8464e1a fix: ui test 2024-08-19 13:07:48 +05:30
Jannat Patel
0170fcc111 Merge pull request #968 from frappe/pot_develop_2024-08-16
chore: update POT file
2024-08-19 13:04:21 +05:30
Jannat Patel
0be5439e81 fix: tests 2024-08-19 12:31:46 +05:30
Jannat Patel
63f857b8fc fix: linters 2024-08-19 12:12:51 +05:30
Jannat Patel
a3b8ed8f91 fix: documented the api 2024-08-19 12:03:32 +05:30
Jannat Patel
cdd46667f3 feat: add new member 2024-08-19 11:47:17 +05:30
frappe-pr-bot
2f8acea988 chore: update POT file 2024-08-16 16:04:13 +00:00
Jannat Patel
75f0e5b9f1 feat: search member 2024-08-16 20:59:51 +05:30
Jannat Patel
ce51129e84 feat: member list 2024-08-16 11:26:11 +05:30
Jannat Patel
86aa8b0a2a Merge pull request #967 from pateljannat/issues-32
fix: settings ui
2024-08-14 12:47:31 +05:30
Jannat Patel
aeae62a45c chore: linters 2024-08-14 12:35:56 +05:30
Jannat Patel
6b12df44a0 fix: settings ui 2024-08-14 12:12:13 +05:30
Frappe PR Bot
a710183bc7 chore(release): Bumped to Version 2.2.0 2024-08-14 05:57:31 +00:00
Jannat Patel
669316ba14 Merge pull request #965 from pateljannat/make-release
ci: automated release PR
2024-08-14 11:08:26 +05:30
Jannat Patel
6c18f9a02f ci: automated release PR 2024-08-14 10:44:11 +05:30
Jannat Patel
363edb9a50 Merge pull request #964 from pateljannat/settings
feat: Settings
2024-08-13 19:14:07 +05:30
Jannat Patel
afbf64170a fix: removed old settings 2024-08-13 19:03:17 +05:30
Jannat Patel
14f36d0c64 chore: removed unnecessary file 2024-08-13 18:59:39 +05:30
Jannat Patel
ceecab395b feat: settings 2024-08-13 18:53:27 +05:30
Jannat Patel
b8eb9fd717 Merge branch 'develop' of https://github.com/frappe/lms into settings 2024-08-13 09:42:17 +05:30
Jannat Patel
230a52f06b Merge pull request #963 from pateljannat/issues-31
fix: misc issues
2024-08-13 09:16:12 +05:30
Jannat Patel
3e82608d5f chore: fixed linters 2024-08-12 20:13:57 +05:30
Jannat Patel
cf2c2345c3 fix: discussions text 2024-08-12 20:10:10 +05:30
Jannat Patel
05ebe4b787 fix: lesson structure issue 2024-08-12 20:09:56 +05:30
Frappe PR Bot
a744a43d14 chore(release): Bumped to Version 2.1.0 2024-08-12 12:32:42 +00:00
Jannat Patel
5abdbfec1f Merge pull request #962 from pateljannat/posthog
chore: product analytics
2024-08-12 18:01:45 +05:30
Jannat Patel
0335b3b4d0 chore: fixed linters 2024-08-12 17:38:59 +05:30
Jannat Patel
703fafd6c3 chore: analytics 2024-08-12 17:13:31 +05:30
Jannat Patel
b956c4e383 Merge branch 'develop' of https://github.com/frappe/lms into posthog 2024-08-12 14:17:04 +05:30
Jannat Patel
d0d1fb2c8c Merge pull request #944 from pateljannat/quiz-creation
Quiz creation
2024-08-12 14:13:23 +05:30
Jannat Patel
d18a6f6e73 chore: removed workspaces from package.json 2024-08-12 13:13:21 +05:30
Jannat Patel
2994144718 feat: quiz creation from lesson form 2024-08-12 12:47:17 +05:30
Jannat Patel
62ab853605 feat: quiz creation from lesson form 2024-08-12 12:46:38 +05:30
Jannat Patel
7f7986d77a chore: fixed linters 2024-08-12 10:04:42 +05:30
Jannat Patel
61f01cc51b chore: resolved conflicts 2024-08-12 10:02:22 +05:30
Jannat Patel
86af8c6301 Merge pull request #960 from mohsinalimat/patch-1
fix: lms setting to lms settings
2024-08-09 22:21:35 +05:30
Jannat Patel
f1b0fcfbfc Merge pull request #961 from frappe/pot_develop_2024-08-09
chore: update POT file
2024-08-09 21:50:21 +05:30
frappe-pr-bot
ab5ce39645 chore: update POT file 2024-08-09 16:04:06 +00:00
Jannat Patel
685e09ce4b feat: question update 2024-08-09 20:38:14 +05:30
MohsinAli
8ed4f775e5 fix: lms setting to lms settings 2024-08-09 13:58:50 +05:30
Jannat Patel
a3a3085b1f fix: update question title in quiz 2024-08-08 22:16:21 +05:30
Jannat Patel
ed97640107 feat: allow updating questions 2024-08-07 18:15:50 +05:30
Jannat Patel
a9e93a679b feat: posthog initialization 2024-08-07 11:31:58 +05:30
Jannat Patel
418c36c09f Merge pull request #957 from frappe/pot_develop_2024-08-06
chore: update POT file
2024-08-06 12:17:49 +05:30
frappe-pr-bot
935f7f1f7b chore: update POT file 2024-08-06 06:32:08 +00:00
Jannat Patel
9a0056b6ca Merge branch 'develop' of https://github.com/frappe/lms into develop 2024-08-06 11:50:23 +05:30
Jannat Patel
cd56da5d85 fix: changed upstream command for pot file generation 2024-08-06 11:47:37 +05:30
Jannat Patel
97d5d853fc Merge pull request #955 from pateljannat/issues-30
fix: lesson auto save
2024-08-06 11:11:50 +05:30
Jannat Patel
8adfe247b2 fix: lesson auto save 2024-08-05 18:14:35 +05:30
Jannat Patel
afe7df2989 fix: fetch question 2024-08-05 16:29:43 +05:30
Jannat Patel
cdb028c69c feat: settings 2024-08-05 15:12:45 +05:30
Jannat Patel
eed330662b Merge pull request #953 from pateljannat/issues-29
fix: course cards home page rendering
2024-08-05 14:20:36 +05:30
Jannat Patel
26db10bbe0 fix: course cards home page rendering 2024-08-05 14:04:08 +05:30
Jannat Patel
14230bd588 Merge pull request #951 from pateljannat/issues-28
fix: spacing and widths
2024-08-05 11:32:12 +05:30
Jannat Patel
699c821edd fix: spacing and widths 2024-08-05 11:12:34 +05:30
Jannat Patel
27ca13ece6 feat: add questions to quiz 2024-08-02 20:20:43 +05:30
Jannat Patel
6820dfc820 fix: make question attachments public 2024-08-01 13:02:20 +05:30
Jannat Patel
e0855a2c1b fix: quiz question population issue 2024-07-31 22:30:29 +05:30
Jannat Patel
6a0b37a4d4 chore: changed release branch to develop 2024-07-31 12:43:04 +05:30
Jannat Patel
f7fd6916e2 chore: release notes action 2024-07-31 12:15:24 +05:30
Jannat Patel
30e61f4b7c Merge pull request #947 from pateljannat/semantic-release
chore: semantic release action
2024-07-31 11:45:42 +05:30
Jannat Patel
48b37d58d8 chore: semantic release action 2024-07-31 11:10:47 +05:30
Jannat Patel
e96f18df7c Merge pull request #946 from pateljannat/issues-27
fix: quiz shuffle issue
2024-07-30 18:04:26 +05:30
Jannat Patel
7d15527831 fix: quiz shuffle issue 2024-07-30 16:58:09 +05:30
Jannat Patel
794c0e760b Merge pull request #945 from pateljannat/issues-26
fix: eval request issues
2024-07-30 15:26:46 +05:30
Jannat Patel
e46a60d00a chore: linters 2024-07-30 15:05:24 +05:30
Jannat Patel
819aac70fd fix: linters 2024-07-30 14:54:21 +05:30
Jannat Patel
ed7db2d7c5 fix: eval request issues 2024-07-30 14:17:17 +05:30
111 changed files with 8301 additions and 6505 deletions

View File

@@ -22,7 +22,7 @@ git config user.name "frappe-pr-bot"
echo "Setting the correct git remote..."
# Here, the git remote is a local file path by default. Let's change it to the upstream repo.
git remote add upstream https://github.com/frappe/lms.git
git remote set-url upstream https://github.com/frappe/lms.git
echo "Creating a new branch..."
isodate=$(date -u +"%Y-%m-%d")

View File

@@ -5,7 +5,7 @@ on:
workflow_dispatch:
jobs:
regeneratee-pot-file:
regenerate-pot-file:
name: Release
runs-on: ubuntu-latest
strategy:

27
.github/workflows/make_release_pr.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Create weekly release
on:
schedule:
# 13:00 UTC -> 7pm IST on every Wednesday
- cron: '30 4 * * 3'
workflow_dispatch:
jobs:
release:
name: Release
runs-on: ubuntu-latest
strategy:
fail-fast: false
steps:
- uses: octokit/request-action@v2.x
with:
route: POST /repos/{owner}/{repo}/pulls
owner: frappe
repo: lms
title: |-
"chore: merge 'develop' into 'main'"
body: "Automated weekly release"
base: main
head: develop
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

32
.github/workflows/on_release.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Generate Semantic Release
on:
workflow_dispatch:
push:
branches:
- main
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout Entire Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup dependencies
run: |
npm install @semantic-release/git @semantic-release/exec --no-save
- name: Create Release
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GIT_AUTHOR_NAME: "Frappe PR Bot"
GIT_AUTHOR_EMAIL: "developers@frappe.io"
GIT_COMMITTER_NAME: "Frappe PR Bot"
GIT_COMMITTER_EMAIL: "developers@frappe.io"
run: npx semantic-release

39
.github/workflows/release_notes.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
# This action:
#
# 1. Generates release notes using github API.
# 2. Strips unnecessary info like chore/style etc from notes.
# 3. Updates release info.
name: 'Release Notes'
on:
workflow_dispatch:
inputs:
tag_name:
description: 'Tag of release like v2.0.0'
required: true
type: string
release:
types: [released]
permissions:
contents: read
jobs:
regen-notes:
name: 'Regenerate release notes'
runs-on: ubuntu-latest
steps:
- name: Update notes
run: |
NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/generate-notes -f tag_name=$RELEASE_TAG \
| jq -r '.body' \
| sed -E '/^\* (chore|ci|test|docs|style)/d' \
| sed -E 's/by @mergify //'
)
RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/tags/$RELEASE_TAG | jq -r '.id')
gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/$RELEASE_ID -f body="$NEW_NOTES"
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
RELEASE_TAG: ${{ github.event.inputs.tag_name || github.event.release.tag_name }}

View File

@@ -99,6 +99,7 @@ jobs:
cd ~/frappe-bench/
bench --site lms.test execute frappe.utils.install.complete_setup_wizard
bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user
bench --site lms.test set-password frappe@example.com admin
- name: cypress pre-requisites
run: |

2
.gitmodules vendored
View File

@@ -1,3 +1,3 @@
[submodule "frappe-ui"]
path = frappe-ui
url = https://github.com/pateljannat/frappe-ui
url = https://github.com/frappe/frappe-ui

21
.releaserc Normal file
View File

@@ -0,0 +1,21 @@
{
"branches": ["develop"],
"plugins": [
"@semantic-release/commit-analyzer", {
"preset": "angular"
},
"@semantic-release/release-notes-generator",
[
"@semantic-release/exec", {
"prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" lms/__init__.py'
}
],
[
"@semantic-release/git", {
"assets": ["lms/__init__.py"],
"message": "chore(release): Bumped to Version ${nextRelease.version}"
}
],
"@semantic-release/github"
]
}

View File

@@ -61,21 +61,7 @@ describe("Course Creation", () => {
cy.wait(1000);
cy.get("label").contains("Title").type("Test Lesson");
/* cy.get("#content .ce-block")
.click()
.invoke("text", "https://www.youtube.com/watch?v=GoDtyItReto"); */
/* cy.get("#content .ce-block")
.click()
.paste("https://www.youtube.com/watch?v=GoDtyItReto"); */
cy.fixture("Youtube.mov", "base64").then((fileContent) => {
cy.get('input[type="file"]').attachFile({
fileContent,
fileName: "Youtube.mov",
mimeType: "image/png",
encoding: "base64",
});
});
cy.get("#content .ce-block").type(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
@@ -119,12 +105,6 @@ describe("Course Creation", () => {
cy.url().should("include", "/learn/1-1");
cy.get("div").contains("Test Lesson");
cy.get("video")
.should("be.visible")
.children("source")
.invoke("attr", "src")
.should("include", "/files/Youtube");
cy.get("div").contains(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);

View File

@@ -35,7 +35,6 @@ bench new-site lms.localhost \
bench --site lms.localhost install-app lms
bench --site lms.localhost set-config developer_mode 1
bench --site lms.localhost clear-cache
bench --site lms.localhost set-config mute_emails 1
bench use lms.localhost
bench start

View File

@@ -21,7 +21,7 @@
"chart.js": "^4.4.1",
"dayjs": "^1.11.6",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.56",
"frappe-ui": "^0.1.69",
"lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0",
"pinia": "^2.0.33",

BIN
frontend/public/Quiz.mp4 Normal file

Binary file not shown.

BIN
frontend/public/Upload.mp4 Normal file

Binary file not shown.

Binary file not shown.

BIN
frontend/public/Youtube.mp4 Normal file

Binary file not shown.

View File

@@ -8,10 +8,12 @@
<script setup>
import { Toasts } from 'frappe-ui'
import { Dialogs } from '@/utils/dialogs'
import { computed, defineAsyncComponent } from 'vue'
import { computed, onMounted, onUnmounted } from 'vue'
import { useScreenSize } from './utils/composables'
import DesktopLayout from './components/DesktopLayout.vue'
import MobileLayout from './components/MobileLayout.vue'
import { stopSession } from '@/telemetry'
import { init as initTelemetry } from '@/telemetry'
const screenSize = useScreenSize()
@@ -22,4 +24,12 @@ const Layout = computed(() => {
return DesktopLayout
}
})
onMounted(async () => {
await initTelemetry()
})
onUnmounted(() => {
stopSession()
})
</script>

View File

@@ -7,7 +7,7 @@
class="flex flex-col overflow-hidden"
:class="isSidebarCollapsed ? 'items-center' : ''"
>
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
<UserDropdown :isCollapsed="isSidebarCollapsed" />
<div class="flex flex-col" v-if="sidebarSettings.data">
<SidebarLink
v-for="link in sidebarLinks"
@@ -100,7 +100,7 @@ import { ChevronRight, Plus } from 'lucide-vue-next'
import { createResource, Button } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue'
const { user } = sessionStore()
const { user, sidebarSettings } = sessionStore()
const { userResource } = usersStore()
const socket = inject('$socket')
const unreadCount = ref(0)
@@ -115,6 +115,20 @@ onMounted(() => {
unreadNotifications.reload()
})
addNotifications()
sidebarSettings.reload(
{},
{
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (!parseInt(data[key])) {
sidebarLinks.value = sidebarLinks.value.filter(
(link) => link.label.toLowerCase().split(' ').join('_') !== key
)
}
})
},
}
)
})
const unreadNotifications = createResource({
@@ -153,21 +167,6 @@ const addNotifications = () => {
}
}
const sidebarSettings = createResource({
url: 'lms.lms.api.get_sidebar_settings',
cache: 'Sidebar Settings',
auto: true,
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (!parseInt(data[key])) {
sidebarLinks.value = sidebarLinks.value.filter(
(link) => link.label.toLowerCase().split(' ').join('_') !== key
)
}
})
},
})
const openPageModal = (link) => {
showPageModal.value = true
pageToEdit.value = link

View File

@@ -0,0 +1,67 @@
<template>
<Popover placement="right-start" class="flex w-full">
<template #target="{ togglePopover }">
<button
:class="[
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-gray-800 hover:bg-gray-100',
]"
@click.prevent="togglePopover()"
>
<div class="flex gap-2">
<LayoutGrid class="size-4 stroke-1.5" />
<span class="whitespace-nowrap">
{{ __('Apps') }}
</span>
</div>
<ChevronRight class="h-4 w-4 stroke-1.5" />
</button>
</template>
<template #body>
<div
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-white shadow-xl"
>
<div v-for="app in apps.data" key="name">
<a
:href="app.route"
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-3 hover:bg-gray-100"
>
<img class="size-8" :src="app.logo" />
<div class="text-sm" @click="app.onClick">
{{ app.title }}
</div>
</a>
</div>
</div>
</template>
</Popover>
</template>
<script setup>
import { Popover, createResource } from 'frappe-ui'
import { LayoutGrid, ChevronRight } from 'lucide-vue-next'
const apps = createResource({
url: 'frappe.apps.get_apps',
cache: 'apps',
auto: true,
transform: (data) => {
let _apps = [
{
name: 'frappe',
logo: '/assets/lms/images/desk.png',
title: __('Desk'),
route: '/app',
},
]
data.map((app) => {
if (app.name === 'lms') return
_apps.push({
name: app.name,
logo: app.logo,
title: __(app.title),
route: app.route,
})
})
return _apps
},
})
</script>

View File

@@ -1,7 +1,15 @@
<template>
<div>
<div class="text-lg font-semibold mb-4">
{{ __('Assessments') }}
<div class="flex items-center justify-between">
<div class="text-lg font-semibold mb-4">
{{ __('Assessments') }}
</div>
<Button v-if="canSeeAddButton()" @click="showModal = true">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
</div>
<div v-if="assessments.data?.length">
<ListView
@@ -9,41 +17,76 @@
:rows="assessments.data"
row-key="name"
:options="{
selectable: false,
showTooltip: false,
getRowRoute: (row) => {
if (row.submission) {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: row.submission.name,
},
}
} else {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: 'new',
},
}
}
},
getRowRoute: (row) => getRowRoute(row),
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in getAssessmentColumns()">
<template #prefix="{ item }">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in assessments.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAssessments(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
<div v-else class="text-sm italic text-gray-600">
{{ __('No Assessments') }}
</div>
</div>
<AssessmentModal
v-model="showModal"
v-model:assessments="assessments"
:batch="props.batch"
/>
</template>
<script setup>
import { ListView, createResource } from 'frappe-ui'
import { inject } from 'vue'
import {
ListView,
ListRow,
ListRows,
ListHeader,
ListHeaderItem,
ListRowItem,
ListSelectBanner,
createResource,
Button,
} from 'frappe-ui'
import { inject, ref } from 'vue'
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
import { Plus, Trash2 } from 'lucide-vue-next'
const user = inject('$user')
const showModal = ref(false)
const props = defineProps({
batch: {
@@ -74,6 +117,61 @@ const assessments = createResource({
auto: true,
})
const deleteAssessments = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'LMS Assessment',
documents: values.assessments,
}
},
})
const removeAssessments = (selections, unselectAll) => {
deleteAssessments.submit(
{ assessments: Array.from(selections) },
{
onSuccess(data) {
assessments.reload()
unselectAll()
},
}
)
}
const getRowRoute = (row) => {
if (row.assessment_type == 'LMS Assignment') {
if (row.submission) {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: row.submission.name,
},
}
} else {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: 'new',
},
}
}
} else {
return {
name: 'Quiz',
params: {
quizID: row.assessment_name,
},
}
}
}
const canSeeAddButton = () => {
return user.data?.is_moderator || user.data?.is_evaluator
}
const getAssessmentColumns = () => {
let columns = [
{

View File

@@ -3,7 +3,7 @@
class="flex flex-col shadow hover:bg-gray-100 rounded-md p-4 h-full"
style="min-height: 150px"
>
<div class="text-xl font-semibold mb-2">
<div class="text-lg leading-5 font-semibold mb-2">
{{ batch.title }}
</div>
<Badge
@@ -22,18 +22,17 @@
>
{{ __('Sold Out') }}
</Badge>
<div class="short-introduction">
<div class="short-introduction text-sm text-gray-700">
{{ batch.description }}
</div>
<div v-if="batch.amount" class="font-semibold mb-4">
{{ batch.price }}
</div>
<div class="flex flex-col space-y-2 mt-auto">
<div v-if="batch.amount" class="font-semibold text-lg">
{{ batch.price }}
</div>
<DateRange
:startDate="batch.start_date"
:endDate="batch.end_date"
class="text-sm text-gray-700 mb-3"
class="text-sm text-gray-700"
/>
<div class="flex items-center text-sm text-gray-700">
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
@@ -50,18 +49,21 @@
{{ batch.timezone }}
</span>
</div>
<div v-if="batch.instructors?.length" class="flex avatar-group overlap">
<div
class="h-6 mr-1"
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
>
<UserAvatar
v-for="instructor in batch.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.instructors" />
</div>
<div
v-if="batch.instructors?.length"
class="flex avatar-group overlap mt-4"
>
<div
class="h-6 mr-1"
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
>
<UserAvatar
v-for="instructor in batch.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.instructors" />
</div>
</div>
</template>
@@ -88,7 +90,7 @@ const props = defineProps({
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
margin: 0.25rem 0 1.25rem;
margin: 0.25rem 0 1rem;
line-height: 1.5;
}

View File

@@ -4,15 +4,11 @@
<div class="text-xl font-semibold">
{{ __('Courses') }}
</div>
<Button
v-if="user.data?.is_moderator"
variant="solid"
@click="openCourseModal()"
>
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add Course') }}
{{ __('Add') }}
</Button>
</div>
<div v-if="courses.data?.length">
@@ -88,6 +84,7 @@ import {
ListRowItem,
} from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next'
import { showToast } from '@/utils'
const showCourseModal = ref(false)
const user = inject('$user')
@@ -132,23 +129,32 @@ const getCoursesColumns = () => {
]
}
const removeCourse = createResource({
url: 'frappe.client.delete',
const deleteCourses = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'Batch Course',
name: values.course,
documents: values.courses,
}
},
})
const removeCourses = (selections, unselectAll) => {
selections.forEach(async (course) => {
removeCourse.submit({ course })
})
setTimeout(() => {
courses.reload()
unselectAll()
}, 1000)
deleteCourses.submit(
{
courses: Array.from(selections),
},
{
onSuccess(data) {
courses.reload()
showToast(__('Success'), __('Courses deleted successfully'), 'check')
unselectAll()
},
}
)
}
const canSeeAddButton = () => {
return user.data?.is_moderator || user.data?.is_evaluator
}
</script>

View File

@@ -81,7 +81,7 @@
<router-link
v-if="isModerator"
:to="{
name: 'BatchCreation',
name: 'BatchForm',
params: {
batchName: batch.data.name,
},

View File

@@ -1,9 +1,9 @@
<template>
<Button class="float-right mb-3" variant="solid" @click="openStudentModal()">
<Button class="float-right mb-3" @click="openStudentModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add Student') }}
{{ __('Add') }}
</Button>
<div class="text-lg font-semibold mb-4">
{{ __('Students') }}
@@ -88,6 +88,7 @@ import {
import { Trash2, Plus } from 'lucide-vue-next'
import { ref } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue'
import { showToast } from '@/utils'
const showStudentModal = ref(false)
@@ -135,23 +136,28 @@ const openStudentModal = () => {
showStudentModal.value = true
}
const removeStudent = createResource({
url: 'frappe.client.delete',
const deleteStudents = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'Batch Student',
name: values.student,
documents: values.students,
}
},
})
const removeStudents = (selections, unselectAll) => {
selections.forEach(async (student) => {
removeStudent.submit({ student })
})
setTimeout(() => {
students.reload()
unselectAll()
}, 500)
deleteStudents.submit(
{
students: Array.from(selections),
},
{
onSuccess(data) {
students.reload()
showToast(__('Success'), __('Students deleted successfully'), 'check')
unselectAll()
},
}
)
}
</script>

View File

@@ -75,7 +75,7 @@
>
<li
:class="[
'flex items-center rounded px-2.5 py-1.5 text-base',
'flex items-center rounded px-2.5 py-2 text-base',
{ 'bg-gray-100': active },
]"
>
@@ -87,7 +87,16 @@
name="item-label"
v-bind="{ active, selected, option }"
>
{{ option.label }}
<div class="flex flex-col space-y-1">
<div>
{{ option.label }}
</div>
<div
v-if="option.label != option.description"
class="text-xs text-gray-700"
v-html="option.description"
></div>
</div>
</slot>
</li>
</ComboboxOption>

View File

@@ -108,6 +108,7 @@ const options = createResource({
url: 'frappe.desk.search.search_link',
cache: [props.doctype, text.value],
method: 'POST',
auto: true,
params: {
txt: text.value,
doctype: props.doctype,
@@ -118,6 +119,7 @@ const options = createResource({
return {
label: option.value,
value: option.value,
description: option.description,
}
})
},

View File

@@ -1,18 +1,28 @@
<template>
<div class="flex text-center">
<div v-for="index in 5">
<Star
:class="index <= rating ? 'fill-orange-500' : ''"
class="h-6 w-6 fill-gray-400 text-gray-50 mr-1 cursor-pointer"
@click="markRating(index)"
/>
<div class="space-y-1">
<label class="block text-xs text-gray-600" v-if="props.label">
{{ props.label }}
</label>
<div class="flex text-center">
<div
v-for="index in 5"
@mouseover="hoveredRating = index"
@mouseleave="hoveredRating = 0"
>
<Star
class="fill-gray-400 text-gray-50 stroke-1 mr-1 cursor-pointer"
:class="iconClasses(index)"
@click="markRating(index)"
/>
</div>
</div>
</div>
</template>
<script setup>
import { Star } from 'lucide-vue-next'
import { ref } from 'vue'
import { computed, ref, watch } from 'vue'
const props = defineProps({
id: {
type: String,
@@ -22,10 +32,36 @@ const props = defineProps({
type: Number,
default: 0,
},
label: {
type: String,
default: '',
},
size: {
type: String,
default: 'md',
},
})
const iconClasses = (index) => {
let classes = [
{
sm: 'size-4',
md: 'size-5',
lg: 'size-6',
xl: 'size-7',
}[props.size],
]
if (index <= hoveredRating.value && index > rating.value) {
classes.push('fill-yellow-200')
} else if (index <= rating.value) {
classes.push('fill-yellow-500')
}
return classes.join(' ')
}
const emit = defineEmits(['update:modelValue'])
let rating = ref(props.modelValue)
const rating = ref(props.modelValue)
const hoveredRating = ref(0)
let emitChange = (value) => {
emit('update:modelValue', value)
@@ -35,4 +71,11 @@ function markRating(index) {
emitChange(index)
rating.value = index
}
watch(
() => props.modelValue,
(newVal) => {
rating.value = newVal
}
)
</script>

View File

@@ -72,7 +72,7 @@
{{ course.title }}
</div>
<div class="short-introduction">
<div class="short-introduction text-gray-700 text-sm">
{{ course.short_introduction }}
</div>

View File

@@ -75,7 +75,7 @@
<router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
name: 'CreateCourse',
name: 'CourseForm',
params: {
courseName: course.data.name,
},
@@ -117,6 +117,7 @@ import { BookOpen, Users, Star } from 'lucide-vue-next'
import { computed, inject } from 'vue'
import { Button, createResource } from 'frappe-ui'
import { createToast } from '@/utils/'
import { capture } from '@/telemetry'
import { useRouter } from 'vue-router'
const router = useRouter()
@@ -155,6 +156,9 @@ function enrollStudent() {
course: props.course.data.name,
})
.then(() => {
capture('enrolled_in_course', {
course: props.course.data.name,
})
createToast({
title: 'Enrolled Successfully',
icon: 'check',

View File

@@ -4,7 +4,7 @@
v-if="title && (outline.data?.length || allowEdit)"
class="grid grid-cols-[70%,30%] mb-4 px-2"
>
<div class="font-semibold text-lg">
<div class="font-semibold text-lg leading-5">
{{ __(title) }}
</div>
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
@@ -41,6 +41,7 @@
<DisclosurePanel>
<Draggable
:list="chapter.lessons"
:disabled="!allowEdit"
item-key="name"
group="items"
@end="updateOutline"
@@ -50,7 +51,7 @@
<div class="outline-lesson pl-8 py-2 pr-4">
<router-link
:to="{
name: allowEdit ? 'CreateLesson' : 'Lesson',
name: allowEdit ? 'LessonForm' : 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.number.split('.')[0],
@@ -89,7 +90,7 @@
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
<router-link
:to="{
name: 'CreateLesson',
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: chapter.idx,

View File

@@ -1,7 +1,7 @@
<template>
<div>
<Button v-if="!singleThread" class="float-right" @click="openTopicModal()">
{{ __('New {0}').format(title) }}
{{ __('New {0}').format(singularize(title)) }}
</Button>
<div class="text-xl font-semibold">
{{ __(title) }}
@@ -65,7 +65,7 @@
<script setup>
import { createResource, Button } from 'frappe-ui'
import UserAvatar from '@/components/UserAvatar.vue'
import { timeAgo } from '../utils'
import { singularize, timeAgo } from '../utils'
import { ref, onMounted, inject } from 'vue'
import DiscussionReplies from '@/components/DiscussionReplies.vue'
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'

View File

@@ -0,0 +1,74 @@
<template>
<div class="space-y-5">
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('quiz')"
>
<span>
{{ __('How to add a Quiz?') }}
</span>
<Info class="w-3 h-3 text-gray-700" />
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('upload')"
>
<span class="leading-5">
{{ __('How to upload content from your system?') }}
</span>
<Info class="w-3 h-3 text-gray-700" />
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('youtube')"
>
<span>
{{ __('How to add a YouTube Video?') }}
</span>
<Info class="w-3 h-3 text-gray-700" />
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'Copy the URL of the video from YouTube and paste it in the editor.'
)
}}
</div>
</div>
</div>
<ExplanationVideos v-model="showExplanation" :type="type" />
</template>
<script setup>
import { Info } from 'lucide-vue-next'
import { ref } from 'vue'
import ExplanationVideos from '@/components/Modals/ExplanationVideos.vue'
const showExplanation = ref(false)
const type = ref(null)
const openHelpDialog = (contentType) => {
type.value = contentType
showExplanation.value = true
}
</script>

View File

@@ -1,163 +0,0 @@
<template>
<div class="text-lg font-semibold">
{{ __('Components') }}
</div>
<div class="mt-5">
<Tooltip
:text="
__(
'Content such as quiz, video and image will be added in the editor you select.'
)
"
placement="bottom"
>
<div class="">
<div class="text-xs text-gray-600 mb-1">
{{ __('Select an Editor') }}
</div>
<Select v-model="currentEditor" :options="getEditorOptions()" />
</div>
</Tooltip>
<div class="flex mt-4">
<Link
v-model="quiz"
class="flex-1"
doctype="LMS Quiz"
:label="__('Select a Quiz')"
/>
<Button @click="addQuiz()" class="self-end ml-2">
<template #icon>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
<div class="mt-4">
<div class="text-xs text-gray-600 mb-1">
{{ __('Add an image, video, pdf or audio.') }}
</div>
<div class="flex">
<FileUploader
v-if="!file"
:fileTypes="['image/*', 'video/*', 'audio/*', '.pdf']"
:validateFile="validateFile"
@success="(data) => addFile(data)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading
? __('Uploading {0}%').format(progress)
: __('Upload a File')
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="">
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-4 w-4 stroke-1.5 text-gray-700" />
</div>
<div class="flex flex-col">
<span class="text-xs">
{{ file.file_name }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4">
<div class="text-xs text-gray-600 mb-1">
{{
__(
'To add a YouTube video, paste the URL of the video in the editor.'
)
}}
</div>
<YouTubeExplanation>
<template v-slot="{ togglePopover }">
<div
@click="togglePopover()"
class="flex items-center text-sm underline cursor-pointer"
>
<Info class="w-3 h-3 stroke-1.5 text-gray-700 mr-1" />
{{ __('Learn More') }}
</div>
</template>
</YouTubeExplanation>
</div>
</div>
</template>
<script setup>
import Link from '@/components/Controls/Link.vue'
import { FileUploader, Button, Select, Tooltip } from 'frappe-ui'
import { Plus, FileText, Info } from 'lucide-vue-next'
import { ref, watch } from 'vue'
import YouTubeExplanation from '@/components/Modals/YouTubeExplanation.vue'
const quiz = ref(null)
const file = ref(null)
const lessonEditor = ref(null)
const instructorEditor = ref(null)
const currentEditor = ref('Lesson Content')
const props = defineProps({
editor: {
required: true,
},
notesEditor: {
required: true,
},
})
const addQuiz = () => {
getCurrentEditor().caret.setToLastBlock('end', 0)
if (quiz.value) {
getCurrentEditor().blocks.insert('quiz', {
quiz: quiz.value,
})
quiz.value = null
}
}
const addFile = (data) => {
getCurrentEditor().caret.setToLastBlock('end', 0)
getCurrentEditor().blocks.insert('upload', data)
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3', 'pdf'].includes(extension)) {
return 'Only image and video files are allowed.'
}
}
const getEditorOptions = () => {
return [
{
label: 'Lesson Content',
value: 'Lesson Content',
},
{
label: 'Instructor Content',
value: 'Instructor Content',
},
]
}
const getCurrentEditor = () => {
return currentEditor.value == 'Lesson Content'
? lessonEditor.value
: instructorEditor.value
}
watch(
() => [props.editor, props.notesEditor],
([newEditor, newNotesEditor], [oldEditor, oldNotesEditor]) => {
lessonEditor.value = newEditor
instructorEditor.value = newNotesEditor
}
)
</script>

View File

@@ -0,0 +1,203 @@
<template>
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between">
<div>
<div class="text-xl font-semibold mb-1">
{{ __(label) }}
</div>
<!-- <div class="text-xs text-gray-600">
{{ __(description) }}
</div> -->
</div>
<div class="flex item-center space-x-2">
<FormControl
v-model="search"
:placeholder="__('Search')"
type="text"
:debounce="300"
/>
<Button @click="() => (showForm = !showForm)">
<template #icon>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
<X v-else class="h-3 w-3 stroke-1.5" />
</template>
</Button>
</div>
</div>
<!-- Form to add new member -->
<div v-if="showForm" class="flex items-center space-x-2 my-4">
<FormControl
v-model="member.email"
:placeholder="__('Email')"
type="email"
class="w-full"
/>
<FormControl
v-model="member.first_name"
:placeholder="__('First Name')"
type="test"
class="w-full"
/>
<Button @click="addMember()" variant="subtle">
{{ __('Add') }}
</Button>
</div>
<div class="mt-2 pb-10 overflow-auto">
<!-- Member list -->
<div class="overflow-y-scroll">
<ul class="divide-y">
<li
v-for="member in memberList"
class="grid grid-cols-3 gap-10 py-2 cursor-pointer"
>
<div
@click="openProfile(member.username)"
class="flex items-center space-x-3 col-span-2"
>
<Avatar
:image="member.user_image"
:label="member.full_name"
size="lg"
/>
<div class="space-y-1">
<div class="flex">
<div class="text-gray-900">
{{ member.full_name }}
</div>
<div v-if="getRole(member)">
{{ getRole(member) }}
</div>
</div>
<div class="text-sm text-gray-700">
{{ member.name }}
</div>
</div>
</div>
<div class="flex items-center justify-center text-gray-700 text-sm">
<div v-if="member.last_active">
{{ dayjs(member.last_active).format('DD MMM, YYYY HH:mm a') }}
</div>
<div v-else>-</div>
</div>
</li>
</ul>
</div>
<div
v-if="memberList.length && hasNextPage"
class="flex justify-center mt-4"
>
<Button @click="members.reload()">
<template #prefix>
<RefreshCw class="h-3 w-3 stroke-1.5" />
</template>
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { createResource, Avatar, Button, FormControl } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { ref, watch, reactive, inject } from 'vue'
import { RefreshCw, Plus, X } from 'lucide-vue-next'
const router = useRouter()
const show = defineModel('show')
const search = ref('')
const start = ref(0)
const memberList = ref([])
const hasNextPage = ref(false)
const showForm = ref(false)
const dayjs = inject('$dayjs')
const member = reactive({
email: '',
first_name: '',
})
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
show: {
type: Boolean,
},
})
const members = createResource({
url: 'lms.lms.api.get_members',
makeParams: () => {
return {
search: search.value,
start: start.value,
}
},
onSuccess(data) {
memberList.value = memberList.value.concat(data)
start.value = start.value + 20
hasNextPage.value = data.length === 20
},
auto: true,
})
const openProfile = (username) => {
show.value = false
router.push({
name: 'Profile',
params: {
username: username,
},
})
}
const newMember = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'User',
first_name: member.first_name,
email: member.email,
},
}
},
auto: false,
onSuccess(data) {
show.value = false
router.push({
name: 'Profile',
params: {
username: data.username,
},
})
},
})
const addMember = () => {
newMember.reload()
}
watch(search, () => {
memberList.value = []
start.value = 0
members.reload()
})
const getRole = (role) => {
const map = {
'LMS Student': 'Student',
'Course Creator': 'Instructor',
Moderator: 'Moderator',
'Batch Evaluator': 'Evaluator',
}
return map[role]
}
</script>

View File

@@ -4,14 +4,14 @@
<slot />
</div>
<div
v-if="tabs"
v-if="sidebarSettings.data"
class="fixed flex justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4"
:style="{
gridTemplateColumns: `repeat(${tabs.length}, minmax(0, 1fr))`,
gridTemplateColumns: `repeat(${sidebarLinks.length}, minmax(0, 1fr))`,
}"
>
<button
v-for="tab in tabs"
v-for="tab in sidebarLinks"
:key="tab.label"
:class="isVisible(tab) ? 'block' : 'hidden'"
class="flex flex-col items-center justify-center py-3 transition active:scale-95"
@@ -29,21 +29,38 @@
<script setup>
import { getSidebarLinks } from '../utils'
import { useRouter } from 'vue-router'
import { computed } from 'vue'
import { computed, ref, onMounted } from 'vue'
import { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/user'
import * as icons from 'lucide-vue-next'
const { logout, user } = sessionStore()
const { logout, user, sidebarSettings } = sessionStore()
let { isLoggedIn } = sessionStore()
const router = useRouter()
let { userResource } = usersStore()
const sidebarLinks = ref(getSidebarLinks())
const tabs = computed(() => {
let links = getSidebarLinks()
onMounted(() => {
sidebarSettings.reload(
{},
{
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (!parseInt(data[key])) {
sidebarLinks.value = sidebarLinks.value.filter(
(link) => link.label.toLowerCase().split(' ').join('_') !== key
)
}
})
addAccessLinks()
},
}
)
})
const addAccessLinks = () => {
if (user) {
links.push({
sidebarLinks.value.push({
label: 'Profile',
icon: 'UserRound',
activeFor: [
@@ -54,18 +71,17 @@ const tabs = computed(() => {
'ProfileRoles',
],
})
links.push({
sidebarLinks.value.push({
label: 'Log out',
icon: 'LogOut',
})
} else {
links.push({
sidebarLinks.value.push({
label: 'Log in',
icon: 'LogIn',
})
}
return links
})
}
let isActive = (tab) => {
return tab.activeFor?.includes(router.currentRoute.value.name)

View File

@@ -0,0 +1,86 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add an assessment'),
size: 'sm',
actions: [
{
label: __('Submit'),
variant: 'solid',
onClick: (close) => addAssessment(close),
},
],
}"
>
<template #body-content>
<div class="space-y-4">
<FormControl
type="select"
:options="assessmentTypes"
v-model="assessmentType"
:label="__('Type')"
/>
<Link
v-model="assessment"
:doctype="assessmentType"
:label="__('Assessment')"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, createResource } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { computed, ref } from 'vue'
import { showToast } from '@/utils'
const show = defineModel()
const assessmentType = ref(null)
const assessment = ref(null)
const assessments = defineModel('assessments')
const props = defineProps({
batch: {
type: String,
default: null,
},
})
const assessmentResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Assessment',
parent: props.batch,
parenttype: 'LMS Batch',
parentfield: 'assessment',
assessment_type: assessmentType.value,
assessment_name: assessment.value,
},
}
},
})
const addAssessment = (close) => {
assessmentResource.submit(
{},
{
onSuccess(data) {
assessments.value.reload()
showToast(__('Success'), __('Assessment added successfully'), 'check')
close()
},
}
)
}
const assessmentTypes = computed(() => {
return [
{ label: 'Quiz', value: 'LMS Quiz' },
{ label: 'Assignment', value: 'LMS Assignment' },
]
})
</script>

View File

@@ -15,17 +15,24 @@
}"
>
<template #body-content>
<FormControl label="Title" v-model="chapter.title" class="mb-4" />
<FormControl
ref="chapterInput"
label="Title"
v-model="chapter.title"
class="mb-4"
/>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, createResource } from 'frappe-ui'
import { defineModel, reactive, watch, inject } from 'vue'
import { createToast, formatTime } from '@/utils/'
import { defineModel, reactive, watch, ref } from 'vue'
import { createToast } from '@/utils/'
import { capture } from '@/telemetry'
const show = defineModel()
const outline = defineModel('outline')
const chapterInput = ref(null)
const props = defineProps({
course: {
@@ -36,6 +43,7 @@ const props = defineProps({
type: Object,
},
})
const chapter = reactive({
title: '',
})
@@ -91,10 +99,12 @@ const addChapter = (close) => {
}
},
onSuccess: (data) => {
capture('chapter_created')
chapterReference.submit(
{ name: data.name },
{
onSuccess(data) {
chapter.title = ''
outline.value.reload()
createToast({
text: 'Chapter added successfully',
@@ -158,4 +168,12 @@ watch(
chapter.title = newChapter?.title
}
)
watch(show, () => {
if (show.value) {
setTimeout(() => {
chapterInput.value.$el.querySelector('input').focus()
}, 100)
}
})
</script>

View File

@@ -1,7 +1,7 @@
<template>
<Dialog
:options="{
title: props.title,
title: singularize(props.title),
size: '2xl',
actions: [
{
@@ -35,8 +35,8 @@
</template>
<script setup>
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
import { reactive, defineModel, computed } from 'vue'
import { showToast } from '@/utils'
import { reactive, defineModel } from 'vue'
import { showToast, singularize } from '@/utils'
const topics = defineModel('reloadTopics')

View File

@@ -0,0 +1,378 @@
<template>
<Dialog
v-model="show"
:options="{
size: '2xl',
}"
>
<template #body>
<div class="flex text-base">
<div class="flex flex-col w-1/2 p-5">
<div class="text-lg font-semibold mb-4">
{{ event.title }}
</div>
<div class="flex flex-col space-y-4 text-sm text-gray-800">
<Tooltip :text="__('Email ID')">
<div class="flex items-center space-x-2 w-fit">
<User class="h-4 w-4 stroke-1.5" />
<span>
{{ event.member }}
</span>
</div>
</Tooltip>
<Tooltip :text="__('Course')">
<div class="flex items-center space-x-2 w-fit">
<BookOpen class="h-4 w-4 stroke-1.5" />
<span>
{{ event.course_title }}
</span>
</div>
</Tooltip>
<Tooltip v-if="event.batch_title" :text="__('Batch')">
<div class="flex items-center space-x-2 w-fit">
<Users class="h-4 w-4 stroke-1.5" />
<span>
{{ event.batch_title }}
</span>
</div>
</Tooltip>
<Tooltip :text="__('Date')">
<div class="flex items-center space-x-2 w-fit">
<Calendar class="h-4 w-4 stroke-1.5" />
<span>
{{ dayjs(event.date).format('DD MMM YYYY') }}
</span>
</div>
</Tooltip>
<Tooltip :text="__('Time')">
<div class="flex items-center space-x-2 w-fit">
<Clock class="h-4 w-4 stroke-1.5" />
<span>
{{ formatTime(event.start_time) }} -
{{ formatTime(event.end_time) }}
</span>
</div>
</Tooltip>
</div>
<div class="flex items-center space-x-2 mt-auto">
<Button
v-if="certificate.name"
@click="openCertificate(certificate)"
class="w-full"
>
<template #prefix>
<FileText class="h-4 w-4 stroke-1.5" />
</template>
{{ __('View Certificate') }}
</Button>
<Button v-else @click="openCallLink(event.venue)" class="w-full">
<template #prefix>
<Video class="h-4 w-4 stroke-1.5" />
</template>
<span>
{{ __('Join Meeting') }}
</span>
</Button>
</div>
</div>
<Tabs :tabs="tabs" v-model="tabIndex" class="border-l w-1/2">
<template #default="{ tab }">
<div
v-if="tab.label == 'Evaluation'"
class="flex flex-col space-y-4 p-5"
>
<div class="flex items-center justify-between">
<Rating v-model="evaluation.rating" :label="__('Rating')" />
<FormControl
type="select"
:options="statusOptions"
v-model="evaluation.status"
:label="__('Status')"
class="w-1/2"
/>
</div>
<Textarea
v-model="evaluation.summary"
:label="__('Summary')"
:rows="7"
/>
<Button variant="solid" @click="saveEvaluation()">
{{ __('Save') }}
</Button>
</div>
<div v-else class="flex flex-col space-y-4 p-5">
<FormControl
type="checkbox"
v-model="certificate.published"
:label="__('Published')"
/>
<Link
v-model="certificate.template"
:label="__('Template')"
doctype="Print Format"
:filters="{
doc_type: 'LMS Certificate',
}"
/>
<FormControl
type="date"
v-model="certificate.issue_date"
:label="__('Issue Date')"
/>
<FormControl
type="date"
v-model="certificate.expiry_date"
:label="__('Expiry Date')"
/>
<Button variant="solid" @click="saveCertificate()">
{{ __('Save') }}
</Button>
</div>
</template>
</Tabs>
</div>
</template>
</Dialog>
</template>
<script setup>
import {
Dialog,
Button,
FormControl,
createResource,
Tabs,
Tooltip,
Textarea,
} from 'frappe-ui'
import {
User,
Calendar,
Clock,
Video,
BookOpen,
FileText,
GraduationCap,
Users,
ClipboardList,
} from 'lucide-vue-next'
import { inject, reactive, watch, ref, computed } from 'vue'
import { formatTime, showToast } from '@/utils'
import Rating from '@/components/Controls/Rating.vue'
import Link from '@/components/Controls/Link.vue'
const show = defineModel()
const dayjs = inject('$dayjs')
const tabIndex = ref(0)
const showCertification = ref(false)
const props = defineProps({
event: {
type: [Object, null],
required: true,
},
})
const evaluation = reactive({})
const certificate = reactive({})
const defaultTemplate = createResource({
url: 'frappe.client.get_value',
makeParams(values) {
return {
doctype: 'Property Setter',
fieldname: 'value',
filters: {
doc_type: 'LMS Certificate',
property: 'default_print_format',
},
}
},
auto: true,
onSuccess(data) {
certificate.template = data.value
},
})
const openCallLink = (link) => {
window.open(link, '_blank')
}
const evaluationResource = createResource({
url: 'lms.lms.api.save_evaluation_details',
makeParams(values) {
return {
member: props.event.member,
course: props.event.course,
batch_name: props.event.batch_name,
date: props.event.date,
start_time: props.event.start_time,
end_time: props.event.end_time,
status: evaluation.status,
rating: evaluation.rating,
summary: evaluation.summary,
evaluator: props.event.evaluator,
}
},
auto: false,
onSuccess(data) {
evaluation.name = data.name
},
})
const evaluationDetails = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Certificate Evaluation',
filters: {
member: props.event.member,
course: props.event.course,
},
}
},
onSuccess(data) {
for (const key in data) {
if (key in evaluation) evaluation[key] = data[key]
if (key == 'rating') evaluation.rating = data.rating * 5
if (evaluation.status == 'Pass') showCertification.value = true
}
},
auto: false,
})
const saveEvaluation = () => {
evaluationResource.submit(
{},
{
onSuccess: () => {
if (evaluation.status == 'Pass') {
showCertification.value = true
} else {
show.value = false
}
showToast(__('Success'), __('Evaluation saved successfully'), 'check')
},
}
)
}
const certificateResource = createResource({
url: 'lms.lms.api.save_certificate_details',
makeParams(values) {
return {
member: props.event.member,
course: props.event.course,
batch_name: props.event.batch_name,
published: certificate.published,
issue_date: certificate.issue_date,
expiry_date: certificate.expiry_date,
template: certificate.template,
evaluator: props.event.evaluator,
}
},
auto: false,
onSuccess(data) {
certificate.name = data
},
})
const certificateDetails = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Certificate',
filters: {
member: props.event.member,
course: props.event.course,
},
}
},
onSuccess(data) {
for (const key in data) {
if (key in certificate) certificate[key] = data[key]
certificate.name = data.name
showCertification.value = true
}
},
onError(err) {
certificate.template = defaultTemplate.data.value
},
auto: false,
})
const saveCertificate = () => {
certificateResource.submit(
{},
{
onSuccess: () => {
showToast(__('Success'), __('Certificate saved successfully'), 'check')
},
}
)
}
watch(show, () => {
if (show.value) {
evaluation.rating = 0
evaluation.status = 'Pending'
evaluation.summary = ''
evaluationDetails.reload()
certificate.published = true
certificate.issue_date = dayjs().format('YYYY-MM-DD')
certificate.expiry_date = null
certificate.template = null
certificate.name = null
certificateDetails.reload()
}
})
const openCertificate = (certificate) => {
window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
certificate.name
}&format=${encodeURIComponent(certificate.template)}`
)
}
const statusOptions = computed(() => {
return [
{
value: 'Pending',
label: __('Pending'),
},
{
value: 'In Progress',
label: __('In Progress'),
},
{
value: 'Pass',
label: __('Pass'),
},
{
value: 'Fail',
label: __('Fail'),
},
]
})
const tabs = computed(() => {
const tabsArray = [
{
label: __('Evaluation'),
icon: ClipboardList,
},
]
if (showCertification.value) {
tabsArray.push({
label: __('Certification'),
icon: GraduationCap,
})
}
return tabsArray
})
</script>

View File

@@ -0,0 +1,34 @@
<template>
<Dialog
v-model="show"
:options="{
size: '4xl',
}"
>
<template #body>
<div class="p-4">
<VideoBlock :file="file" />
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog } from 'frappe-ui'
import { computed } from 'vue'
import VideoBlock from '@/components/VideoBlock.vue'
const show = defineModel()
const props = defineProps({
type: {
type: [String, null],
required: true,
},
})
const file = computed(() => {
if (props.type == 'youtube') return '/Youtube.mp4'
if (props.type == 'quiz') return '/Quiz.mp4'
if (props.type == 'upload') return '/Upload.mp4'
})
</script>

View File

@@ -2,46 +2,95 @@
<Dialog v-model="show" :options="dialogOptions">
<template #body-content>
<div class="space-y-4">
<div>
<label class="block text-xs text-gray-600 mb-1">
{{ __('Question') }}
</label>
<TextEditor
:content="question.question"
@change="(val) => (question.question = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
<div
v-if="!editMode"
class="flex items-center text-xs text-gray-700 space-x-5"
>
<div class="flex items-center space-x-2">
<input
type="radio"
id="existing"
value="existing"
v-model="questionType"
class="w-3 h-3 accent-gray-900"
/>
<label for="existing">
{{ __('Add an existing question') }}
</label>
</div>
<div class="flex items-center space-x-2">
<input
type="radio"
id="new"
value="new"
v-model="questionType"
class="w-3 h-3"
/>
<label for="new">
{{ __('Create a new question') }}
</label>
</div>
</div>
<FormControl
:label="__('Type')"
v-model="question.type"
type="select"
:options="['Choices', 'User Input']"
class="pb-2"
/>
<div v-if="question.type == 'Choices'" class="divide-y">
<div v-for="n in 4" class="space-y-4 py-2">
<FormControl
:label="__('Option') + ' ' + n"
v-model="question[`option_${n}`]"
<div v-if="questionType == 'new' || editMode" class="space-y-2">
<div>
<label class="block text-xs text-gray-600 mb-1">
{{ __('Question') }}
</label>
<TextEditor
:content="question.question"
@change="(val) => (question.question = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
<FormControl
v-model="question.marks"
:label="__('Marks')"
type="number"
/>
<FormControl
:label="__('Type')"
v-model="question.type"
type="select"
:options="['Choices', 'User Input']"
class="pb-2"
/>
<div v-if="question.type == 'Choices'" class="divide-y border-t">
<div v-for="n in 4" class="space-y-4 py-2">
<FormControl
:label="__('Option') + ' ' + n"
v-model="question[`option_${n}`]"
/>
<FormControl
:label="__('Explanation')"
v-model="question[`explanation_${n}`]"
/>
<FormControl
:label="__('Correct Answer')"
v-model="question[`is_correct_${n}`]"
type="checkbox"
/>
</div>
</div>
<div v-else v-for="n in 4" class="space-y-2">
<FormControl
:label="__('Explanation')"
v-model="question[`explanation_${n}`]"
/>
<FormControl
:label="__('Correct Answer')"
v-model="question[`correct_answer_${n}`]"
type="checkbox"
:label="__('Possibility') + ' ' + n"
v-model="question[`possibility_${n}`]"
/>
</div>
</div>
<div v-else v-for="n in 4" class="space-y-2">
<div v-else-if="questionType == 'existing'" class="space-y-2">
<Link
v-model="existingQuestion.question"
:label="__('Select a question')"
doctype="LMS Question"
/>
<FormControl
:label="__('Possibility') + ' ' + n"
v-model="question[`possibility_${n}`]"
v-model="existingQuestion.marks"
:label="__('Marks')"
type="number"
/>
</div>
</div>
@@ -50,83 +99,227 @@
</template>
<script setup>
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
import { computed, onMounted, reactive, inject } from 'vue'
import { computed, watch, reactive, ref } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
const show = defineModel()
const user = inject('$user')
const quiz = defineModel('quiz')
const questionType = ref(null)
const editMode = ref(false)
const existingQuestion = reactive({
question: '',
marks: 0,
})
const question = reactive({
question: '',
type: 'Choices',
})
onMounted(() => {
populateFields()
console.log(props.questionName)
if (
props.questionName == 'new' &&
!user.data?.is_moderator &&
!user.data?.is_instructor
) {
router.push({ name: 'Courses' })
}
if (props.courseName !== 'new') {
questionDoc.reload()
}
window.addEventListener('keydown', keyboardShortcut)
})
const props = defineProps({
title: {
type: String,
default: __('Add a Question'),
},
questionName: {
type: String,
},
})
const questionDoc = createResource({
url: 'frappe.client.get',
makeParams: (values) => {
return {
doctype: 'LMS Question',
name: props.questionName,
}
},
onSuccess(data) {
let counter = 1
Object.keys(data).forEach((key) => {
if (Object.hasOwn(question, key)) question[key] = data[key]
})
while (counter <= 4) {
question[`is_correct_${counter}`] = question[`is_correct_${counter}`]
? true
: false
}
},
marks: 0,
})
const populateFields = () => {
let fields = ['option', 'correct_answer', 'explanation', 'possibility']
let fields = ['option', 'is_correct', 'explanation', 'possibility']
let counter = 1
fields.forEach((field) => {
while (counter <= 4) {
question[`${field}_${counter}`] = field === 'correct_answer' ? false : ''
question[`${field}_${counter}`] = field === 'is_correct' ? false : ''
counter++
}
})
}
const keyboardShortcut = (e) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
!e.target.classList.contains('ProseMirror')
) {
submitQuestion()
e.preventDefault()
populateFields()
const props = defineProps({
title: {
type: String,
default: __('Add a new question'),
},
questionDetail: {
type: [Object, null],
required: true,
},
})
const questionData = createResource({
url: 'frappe.client.get',
makeParams() {
return {
doctype: 'LMS Question',
name: props.questionDetail.question,
}
},
auto: false,
onSuccess(data) {
let counter = 1
editMode.value = true
Object.keys(data).forEach((key) => {
if (Object.hasOwn(question, key)) question[key] = data[key]
})
while (counter <= 4) {
question[`is_correct_${counter}`] = data[`is_correct_${counter}`]
? true
: false
counter++
}
question.marks = props.questionDetail.marks
},
})
watch(show, () => {
if (show.value) {
editMode.value = false
if (props.questionDetail.question) questionData.fetch()
else {
;(question.question = ''), (question.marks = 0)
question.type = 'Choices'
existingQuestion.question = ''
existingQuestion.marks = 0
questionType.value = null
populateFields()
}
if (props.questionDetail.marks) question.marks = props.questionDetail.marks
}
})
const questionRow = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Quiz Question',
parent: quiz.value.data.name,
parentfield: 'questions',
parenttype: 'LMS Quiz',
...values,
},
}
},
})
const questionCreation = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Question',
...question,
},
}
},
})
const submitQuestion = (close) => {
if (props.questionDetail?.question) updateQuestion(close)
else addQuestion(close)
}
const addQuestion = (close) => {
if (questionType.value == 'existing') {
addQuestionRow(
{
question: existingQuestion.question,
marks: existingQuestion.marks,
},
close
)
} else {
questionCreation.submit(
{},
{
onSuccess(data) {
addQuestionRow(
{
question: data.name,
marks: question.marks,
},
close
)
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
}
const addQuestionRow = (question, close) => {
questionRow.submit(
{
...question,
},
{
onSuccess() {
show.value = false
showToast(__('Success'), __('Question added successfully'), 'check')
quiz.value.reload()
close()
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
close()
},
}
)
}
const questionUpdate = createResource({
url: 'frappe.client.set_value',
auto: false,
makeParams(values) {
return {
doctype: 'LMS Question',
name: questionData.data?.name,
fieldname: {
...question,
},
}
},
})
const marksUpdate = createResource({
url: 'frappe.client.set_value',
auto: false,
makeParams(values) {
return {
doctype: 'LMS Quiz Question',
name: props.questionDetail.name,
fieldname: {
marks: question.marks,
},
}
},
})
const updateQuestion = (close) => {
questionUpdate.submit(
{},
{
onSuccess() {
marksUpdate.submit(
{},
{
onSuccess() {
show.value = false
showToast(
__('Success'),
__('Question updated successfully'),
'check'
)
quiz.value.reload()
close()
},
}
)
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
const dialogOptions = computed(() => {
@@ -145,3 +338,10 @@ const dialogOptions = computed(() => {
}
})
</script>
<style>
input[type='radio']:checked {
background-color: theme('colors.gray.900') !important;
border-color: theme('colors.gray.900') !important;
--tw-ring-color: theme('colors.gray.900') !important;
}
</style>

View File

@@ -0,0 +1,279 @@
<template>
<Dialog v-model="show" :options="{ size: '4xl' }">
<template #body>
<div class="flex h-[calc(100vh_-_8rem)]">
<div class="flex w-52 shrink-0 flex-col bg-gray-50 p-2">
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold">
{{ __('Settings') }}
</h1>
<div v-for="tab in tabs">
<div
v-if="!tab.hideLabel"
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-gray-600 transition-all duration-300 ease-in-out"
>
<span>{{ __(tab.label) }}</span>
</div>
<nav class="space-y-1">
<SidebarLink
v-for="item in tab.items"
:link="item"
class="w-full"
:class="
activeTab?.label == item.label
? 'bg-white shadow-sm'
: 'hover:bg-gray-100'
"
@click="activeTab = item"
/>
</nav>
</div>
</div>
<div
v-if="activeTab && data.doc"
class="flex flex-1 flex-col px-10 pt-8"
>
<Members
v-if="activeTab.label === 'Members'"
:label="activeTab.label"
:description="activeTab.description"
v-model:show="show"
/>
<SettingDetails
v-else
:fields="activeTab.fields"
:label="activeTab.label"
:description="activeTab.description"
:data="data"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, createDocumentResource } from 'frappe-ui'
import { ref, computed, watch } from 'vue'
import SettingDetails from '../SettingDetails.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import Members from '@/components/Members.vue'
const show = defineModel()
const doctype = ref('LMS Settings')
const activeTab = ref(null)
const data = createDocumentResource({
doctype: doctype.value,
name: doctype.value,
fields: ['*'],
cache: doctype.value,
auto: true,
})
const tabs = computed(() => {
let _tabs = [
{
label: 'Settings',
hideLabel: true,
items: [
{
label: 'Members',
description: 'Manage the members of your learning system',
icon: 'UserRoundPlus',
},
{
label: 'Payment Gateway',
icon: 'DollarSign',
description:
'Configure the payment gateway and other payment related settings',
fields: [
{
label: 'Razorpay Key',
name: 'razorpay_key',
type: 'text',
},
{
label: 'Razorpay Secret',
name: 'razorpay_secret',
type: 'password',
},
{
label: 'Default Currency',
name: 'default_currency',
type: 'Link',
doctype: 'Currency',
},
{
type: 'Column Break',
},
{
label: 'Apply GST for India',
name: 'apply_gst',
type: 'checkbox',
},
{
label: 'Show USD equivalent amount',
name: 'show_usd_equivalent',
type: 'checkbox',
},
{
label: 'Apply rounding on equivalent',
name: 'apply_rounding',
type: 'checkbox',
},
],
},
],
},
{
label: 'Settings',
hideLabel: true,
items: [
{
label: 'Sidebar',
icon: 'PanelLeftIcon',
description: 'Choose the items you want to show in the sidebar',
fields: [
{
label: 'Courses',
name: 'courses',
type: 'checkbox',
},
{
label: 'Batches',
name: 'batches',
type: 'checkbox',
},
{
label: 'Certified Participants',
name: 'certified_participants',
type: 'checkbox',
},
{
type: 'Column Break',
},
{
label: 'Jobs',
name: 'jobs',
type: 'checkbox',
},
{
label: 'Statistics',
name: 'statistics',
type: 'checkbox',
},
{
label: 'Notifications',
name: 'notifications',
type: 'checkbox',
},
],
},
],
},
{
label: 'Settings',
hideLabel: true,
items: [
{
label: 'Email Templates',
icon: 'MailPlus',
description: 'Create email templates with the content you want',
fields: [
{
label: 'Batch Confirmation Template',
name: 'batch_confirmation_template',
doctype: 'Email Template',
type: 'Link',
},
{
label: 'Certification Template',
name: 'certification_template',
doctype: 'Email Template',
type: 'Link',
},
{
label: 'Assignment Submission Template',
name: 'assignment_submission_template',
doctype: 'Email Template',
type: 'Link',
},
],
},
],
},
{
label: 'Settings',
hideLabel: true,
items: [
{
label: 'Signup',
icon: 'LogIn',
description:
'Customize the signup page to inform users about your terms and policies',
fields: [
{
label: 'Show terms of use on signup',
name: 'terms_of_use',
type: 'checkbox',
},
{
label: 'Terms of Use Page',
name: 'terms_page',
type: 'Link',
doctype: 'Web Page',
},
{
label: 'Show privacy policy on signup',
name: 'privacy_policy',
type: 'checkbox',
},
{
label: 'Privacy Policy Page',
name: 'privacy_policy_page',
type: 'Link',
doctype: 'Web Page',
},
{
type: 'Column Break',
},
{
label: 'Show cookie policy on signup',
name: 'cookie_policy',
type: 'checkbox',
},
{
label: 'Cookie Policy Page',
name: 'cookie_policy_page',
type: 'Link',
doctype: 'Web Page',
},
{
label: 'Ask user category during signup',
name: 'user_category',
type: 'checkbox',
},
],
},
],
},
]
return _tabs.map((tab) => {
tab.items = tab.items.filter((item) => {
if (item.condition) {
return item.condition()
}
return true
})
return tab
})
})
watch(show, () => {
if (show.value) {
activeTab.value = tabs.value[0].items[0]
} else {
activeTab.value = null
}
})
</script>

View File

@@ -1,31 +0,0 @@
<template>
<Popover transition="default">
<template #target="{ isOpen, togglePopover }" class="flex w-full">
<slot v-bind="{ isOpen, togglePopover }"></slot>
</template>
<template #body>
<div
class="absolute left-0 mt-3 w-[35rem] max-w-lg -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
>
<div
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
>
<video
controls
autoplay
muted
width="100%"
controlsList="nodownload"
oncontextmenu="return false;"
class="rounded-sm"
>
<source src="/Youtube.mov" type="video/mp4" />
</video>
</div>
</div>
</template>
</Popover>
</template>
<script setup>
import { Popover } from 'frappe-ui'
</script>

View File

@@ -24,7 +24,7 @@
<div>
{{ __('Please login to access this page.') }}
</div>
<Button variant="solid" @click="redirectToLogin()" class="mt-2">
<Button @click="redirectToLogin()" class="mt-4">
{{ __('Login') }}
</Button>
</div>

View File

@@ -3,9 +3,7 @@
<div class="bg-blue-100 py-2 px-2 mb-4 rounded-md text-sm text-blue-800">
<div class="leading-relaxed">
{{
__('This quiz consists of {0} questions.').format(
quiz.data.questions.length
)
__('This quiz consists of {0} questions.').format(questions.length)
}}
</div>
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
@@ -59,7 +57,7 @@
</div>
</div>
<div v-else-if="!quizSubmission.data">
<div v-for="(question, qtidx) in quiz.data.questions">
<div v-for="(question, qtidx) in questions">
<div
v-if="qtidx == activeQuestion - 1 && questionDetails.data"
class="border rounded-md p-5"
@@ -86,7 +84,7 @@
</div>
</div>
<div
class="text-gray-900 font-semibold mt-2"
class="text-gray-900 font-semibold mt-2 leading-5"
v-html="questionDetails.data.question"
></div>
<div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4">
@@ -166,7 +164,7 @@
{{
__('Question {0} of {1}').format(
activeQuestion,
quiz.data.questions.length
questions.length
)
}}
</div>
@@ -179,7 +177,7 @@
</span>
</Button>
<Button
v-else-if="activeQuestion != quiz.data.questions.length"
v-else-if="activeQuestion != questions.length"
@click="nextQuetion()"
>
<span>
@@ -250,6 +248,7 @@ const activeQuestion = ref(0)
const currentQuestion = ref('')
const selectedOptions = reactive([0, 0, 0, 0])
const showAnswers = reactive([])
let questions = reactive([])
const possibleAnswer = ref(null)
const props = defineProps({
@@ -270,15 +269,30 @@ const quiz = createResource({
cache: ['quiz', props.quizName],
auto: true,
onSuccess(data) {
if (data.shuffle_questions) {
data.questions = data.questions.sort(() => Math.random() - 0.5)
}
if (data.limit_questions_to) {
data.questions = data.questions.slice(0, data.limit_questions_to)
}
populateQuestions()
},
})
const populateQuestions = () => {
let data = quiz.data
if (data.shuffle_questions) {
questions = shuffleArray(data.questions)
if (data.limit_questions_to) {
questions = questions.slice(0, data.limit_questions_to)
}
} else {
questions = data.questions
}
}
const shuffleArray = (array) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[array[i], array[j]] = [array[j], array[i]]
}
return array
}
const attempts = createResource({
url: 'frappe.client.get_list',
makeParams(values) {
@@ -310,7 +324,7 @@ const attempts = createResource({
watch(
() => quiz.data,
() => {
if (quiz.data) {
if (quiz.data && quiz.data.max_attempts) {
attempts.reload()
resetQuiz()
}
@@ -425,7 +439,7 @@ const checkAnswer = () => {
const addToLocalStorage = () => {
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
let questionData = {
question_index: activeQuestion.value,
question_name: currentQuestion.value,
answer: getAnswers().join(),
is_correct: showAnswers.filter((answer) => {
return answer != undefined
@@ -464,7 +478,7 @@ const submitQuiz = () => {
const createSubmission = () => {
quizSubmission.reload().then(() => {
attempts.reload()
if (quiz.data && quiz.data.max_attempts) attempts.reload()
})
}
@@ -473,6 +487,7 @@ const resetQuiz = () => {
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
showAnswers.length = 0
quizSubmission.reset()
populateQuestions()
}
const getSubmissionColumns = () => {

View File

@@ -0,0 +1,58 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div class="p-5 space-y-4">
<div class="text-lg font-semibold">
{{ __('Add a quiz to your lesson') }}
</div>
<div>
<Link
v-model="quiz"
doctype="LMS Quiz"
:label="__('Select a quiz')"
:onCreate="(value, close) => redirectToQuizForm()"
/>
</div>
<div class="flex justify-end space-x-2">
<Button variant="solid" @click="addQuiz()">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Button } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const quiz = ref(null)
const props = defineProps({
onQuizAddition: {
type: Function,
required: true,
},
})
onMounted(async () => {
await nextTick()
show.value = true
})
const addQuiz = () => {
props.onQuizAddition(quiz.value)
show.value = false
}
const redirectToQuizForm = () => {
window.open('/lms/quizzes/new', '_blank')
}
</script>

View File

@@ -0,0 +1,96 @@
<template>
<div class="flex flex-col justify-between h-full">
<div>
<div class="font-semibold mb-1">
{{ __(label) }}
</div>
<div class="text-xs text-gray-600">
{{ __(description) }}
</div>
</div>
<div class="flex justify-between my-5">
<div v-for="(column, index) in columns" :key="index">
<div class="flex flex-col space-y-5 w-72">
<div v-for="field in column">
<Link
v-if="field.type == 'Link'"
v-model="field.value"
:doctype="field.doctype"
:label="field.label"
/>
<FormControl
v-else
:key="field.name"
v-model="field.value"
:label="field.label"
:type="field.type"
/>
</div>
</div>
</div>
</div>
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
</div>
</template>
<script setup>
import { FormControl, Button } from 'frappe-ui'
import { computed } from 'vue'
import Link from '@/components/Controls/Link.vue'
const props = defineProps({
fields: {
type: Array,
required: true,
},
data: {
type: Object,
required: true,
},
label: {
type: String,
required: true,
},
description: {
type: String,
},
})
const columns = computed(() => {
const cols = []
let currentColumn = []
props.fields.forEach((field) => {
if (field.type === 'Column Break') {
if (currentColumn.length > 0) {
cols.push(currentColumn)
currentColumn = []
}
} else {
if (field.type == 'checkbox') {
field.value = props.data.doc[field.name] ? true : false
} else {
field.value = props.data.doc[field.name]
}
currentColumn.push(field)
}
})
if (currentColumn.length > 0) {
cols.push(currentColumn)
}
return cols
})
const update = () => {
props.fields.forEach((f) => {
props.data.doc[f.name] = f.value
})
props.data.save.submit()
}
</script>

View File

@@ -0,0 +1,53 @@
<template>
<FileUploader
:fileTypes="['image/*', 'video/*', 'audio/*', '.pdf']"
:validateFile="validateFile"
@success="(data) => addFile(data)"
ref="fileUploader"
class="hide"
/>
</template>
<script setup>
import { FileUploader } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
const fileUploader = ref(null)
const emit = defineEmits(['fileUploaded'])
const props = defineProps({
onFileUploaded: {
type: Function,
required: true,
},
})
onMounted(async () => {
await nextTick()
const fileInput = fileUploader.value.$el.querySelector('input[type="file"]')
if (fileInput) {
fileInput.click()
}
})
const addFile = (file) => {
props.onFileUploaded({
file_url: file.file_url,
file_type: file.file_type,
})
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3', 'pdf'].includes(extension)) {
return 'Only image and video files are allowed.'
}
}
const isVideo = (type) => {
return ['mov', 'mp4', 'avi', 'mkv', 'webm'].includes(type.toLowerCase())
}
const isAudio = (type) => {
return ['mp3', 'wav', 'ogg'].includes(type.toLowerCase())
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<Dropdown :options="userDropdownOptions">
<Dropdown class="p-2" :options="userDropdownOptions">
<template v-slot="{ open }">
<button
class="flex h-12 py-2 items-center rounded-md duration-300 ease-in-out"
@@ -56,24 +56,33 @@
</button>
</template>
</Dropdown>
<SettingsModal
v-if="userResource.data?.is_moderator"
v-model="showSettingsModal"
/>
</template>
<script setup>
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { sessionStore } from '@/stores/session'
import { Dropdown } from 'frappe-ui'
import Apps from '@/components/Apps.vue'
import {
ChevronDown,
LogIn,
LogOut,
User,
ArrowRightLeft,
Settings,
} from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { convertToTitleCase } from '../utils'
import { usersStore } from '@/stores/user'
import { ref, markRaw } from 'vue'
import SettingsModal from '@/components/Modals/Settings.vue'
const router = useRouter()
const showSettingsModal = ref(false)
const { logout, branding } = sessionStore()
let { userResource } = usersStore()
let { isLoggedIn } = sessionStore()
@@ -97,11 +106,7 @@ const userDropdownOptions = [
},
},
{
icon: ArrowRightLeft,
label: 'Switch to Desk',
onClick: () => {
window.location.href = '/app'
},
component: markRaw(Apps),
condition: () => {
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
let system_user = cookies.get('system_user')
@@ -109,6 +114,16 @@ const userDropdownOptions = [
else return false
},
},
{
icon: Settings,
label: 'Settings',
onClick: () => {
showSettingsModal.value = true
},
condition: () => {
return userResource.data?.is_moderator
},
},
{
icon: LogOut,
label: 'Log out',

View File

@@ -4,6 +4,7 @@
@timeupdate="updateTime"
@ended="videoEnded"
class="rounded-lg border border-gray-100"
ref="videoRef"
>
<source :src="fileURL" :type="type" />
</video>
@@ -71,7 +72,6 @@ const props = defineProps({
onMounted(() => {
setTimeout(() => {
videoRef.value = document.querySelector('video')
videoRef.value.onloadedmetadata = () => {
duration.value = videoRef.value.duration
}

View File

@@ -13,13 +13,9 @@
<div class="text-lg font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="grid grid-cols-2 gap-10 mb-4">
<div class="grid grid-cols-2 gap-10 mb-4 space-y-2">
<div>
<FormControl
v-model="batch.title"
:label="__('Title')"
class="mb-4"
/>
<FormControl v-model="batch.title" :label="__('Title')" />
</div>
<div class="flex flex-col space-y-2">
<FormControl
@@ -236,6 +232,7 @@ import MultiSelect from '@/components/Controls/MultiSelect.vue'
import { useRouter } from 'vue-router'
import { getFileSize, showToast } from '../utils'
import { X, FileText } from 'lucide-vue-next'
import { capture } from '@/telemetry'
const router = useRouter()
const user = inject('$user')
@@ -274,6 +271,8 @@ onMounted(() => {
if (!user.data) window.location.href = '/login'
if (props.batchName != 'new') {
batchDetail.reload()
} else {
capture('batch_form_opened')
}
window.addEventListener('keydown', keyboardShortcut)
})
@@ -377,6 +376,7 @@ const createNewBatch = () => {
{},
{
onSuccess(data) {
capture('batch_created')
router.push({
name: 'BatchDetail',
params: {
@@ -447,7 +447,7 @@ const breadcrumbs = computed(() => {
}
crumbs.push({
label: props.batchName == 'new' ? 'New Batch' : 'Edit Batch',
route: { name: 'BatchCreation', params: { batchName: props.batchName } },
route: { name: 'BatchForm', params: { batchName: props.batchName } },
})
return crumbs
})

View File

@@ -5,7 +5,7 @@
>
<Breadcrumbs
class="h-7"
:items="[{ label: __('All Batches'), route: { name: 'Batches' } }]"
:items="[{ label: __('Batches'), route: { name: 'Batches' } }]"
/>
<div class="flex space-x-2">
<div class="w-40">
@@ -19,13 +19,13 @@
<router-link
v-if="user.data?.is_moderator"
:to="{
name: 'BatchCreation',
name: 'BatchForm',
params: { batchName: 'new' },
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4" />
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('New Batch') }}
</Button>

View File

@@ -6,9 +6,10 @@
<div>
<FormControl
type="text"
placeholder="Search Participants"
placeholder="Search"
v-model="searchQuery"
@input="participants.reload()"
class="w-40"
>
<template #prefix>
<Search class="w-4 stroke-1.5 text-gray-600" name="search" />

View File

@@ -227,6 +227,7 @@ import { FileText, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import { capture } from '@/telemetry'
const user = inject('$user')
const newTag = ref('')
@@ -268,6 +269,8 @@ onMounted(() => {
if (props.courseName !== 'new') {
courseResource.reload()
} else {
capture('course_form_opened')
}
window.addEventListener('keydown', keyboardShortcut)
})
@@ -388,9 +391,10 @@ const submitCourse = () => {
} else {
courseCreationResource.submit(course, {
onSuccess(data) {
capture('course_created')
showToast('Success', 'Course created successfully', 'check')
router.push({
name: 'CreateCourse',
name: 'CourseForm',
params: { courseName: data.name },
})
},
@@ -416,7 +420,7 @@ const validateMandatoryFields = () => {
}
}
if (course.paid_course && (!course.course_price || !course.currency)) {
return 'Course price and currency are mandatory for paid courses'
return __('Course price and currency are mandatory for paid courses')
}
}
@@ -432,7 +436,7 @@ watch(
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
return 'Only image file is allowed.'
return __('Only image file is allowed.')
}
}
@@ -489,7 +493,7 @@ const breadcrumbs = computed(() => {
}
crumbs.push({
label: props.courseName == 'new' ? 'New Course' : 'Edit Course',
route: { name: 'CreateCourse', params: { courseName: props.courseName } },
route: { name: 'CourseForm', params: { courseName: props.courseName } },
})
return crumbs
})

View File

@@ -5,22 +5,24 @@
>
<Breadcrumbs
class="h-7"
:items="[{ label: __('All Courses'), route: { name: 'Courses' } }]"
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
/>
<div class="flex space-x-2">
<FormControl
type="text"
placeholder="Search Course"
v-model="searchQuery"
@input="courses.reload()"
>
<template #prefix>
<Search class="w-4 stroke-1.5 text-gray-600" name="search" />
</template>
</FormControl>
<div class="flex space-x-2 justify-end">
<div class="w-36">
<FormControl
type="text"
placeholder="Search"
v-model="searchQuery"
@input="courses.reload()"
>
<template #prefix>
<Search class="w-4 h-4 stroke-1.5 text-gray-600" name="search" />
</template>
</FormControl>
</div>
<router-link
:to="{
name: 'CreateCourse',
name: 'CourseForm',
params: {
courseName: 'new',
},

View File

@@ -50,9 +50,9 @@
</Button>
</div>
</header>
<div v-if="job.data" class="w-3/4 mx-auto">
<div v-if="job.data" class="max-w-3xl mx-auto">
<div class="p-4">
<div class="flex mb-4">
<div class="flex mb-10">
<img
:src="job.data.company_logo"
class="w-16 h-16 rounded-lg object-contain mr-4"
@@ -62,40 +62,36 @@
<div class="text-2xl font-semibold mb-4">
{{ job.data.job_title }}
</div>
<div class="grid grid-cols-3 gap-8">
<div class="grid grid-cols-1 gap-2">
<div class="flex items-center space-x-2">
<Building2 class="h-4 w-4 stroke-1.5" />
<span>{{ job.data.company_name }}</span>
</div>
<div class="flex items-center space-x-2">
<MapPin class="h-4 w-4 stroke-1.5" />
<span>{{ job.data.location }}</span>
</div>
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-2 md:gap-y-4"
>
<div class="flex items-center space-x-2">
<Building2 class="h-4 w-4 stroke-1.5" />
<span>{{ job.data.company_name }}</span>
</div>
<div class="grid grid-cols-1 gap-2">
<div class="flex items-center space-x-2">
<ClipboardType class="h-4 w-4 stroke-1.5" />
<span>{{ job.data.type }}</span>
</div>
<div class="flex items-center space-x-2">
<CalendarDays class="h-4 w-4 stroke-1.5" />
<span>{{
dayjs(job.data.creation).format('DD MMM YYYY')
}}</span>
</div>
<div class="flex items-center space-x-2">
<MapPin class="h-4 w-4 stroke-1.5" />
<span>{{ job.data.location }}</span>
</div>
<div class="grid grid-cols-1 h-fit">
<div
v-if="applicationCount.data"
class="flex items-center space-x-2"
<div class="flex items-center space-x-2">
<ClipboardType class="h-4 w-4 stroke-1.5" />
<span>{{ job.data.type }}</span>
</div>
<div class="flex items-center space-x-2">
<CalendarDays class="h-4 w-4 stroke-1.5" />
<span>
{{ dayjs(job.data.creation).format('DD MMM YYYY') }}
</span>
</div>
<div
v-if="applicationCount.data"
class="flex items-center space-x-2"
>
<SquareUserRound class="h-4 w-4 stroke-1.5" />
<span
>{{ applicationCount.data }}
{{ __('applications received') }}</span
>
<SquareUserRound class="h-4 w-4 stroke-1.5" />
<span
>{{ applicationCount.data }}
{{ __('applications received') }}</span
>
</div>
</div>
</div>
</div>

View File

@@ -58,7 +58,7 @@
<router-link
v-if="allowEdit()"
:to="{
name: 'CreateLesson',
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: props.chapterNumber,
@@ -120,7 +120,7 @@
</div>
<div
v-if="
lesson.data.instructor_content?.blocks?.length &&
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
allowInstructorContent()
"
class="bg-gray-100 p-3 rounded-md mt-6"
@@ -244,7 +244,7 @@ const lesson = createResource({
onSuccess(data) {
lessonProgress.value = data.membership?.progress
if (data.content) editor.value = renderEditor('editor', data.content)
if (data.instructor_content?.blocks?.length)
if (JSON.parse(data.instructor_content)?.blocks?.length > 1)
instructorEditor.value = renderEditor(
'instructor-content',
data.instructor_content
@@ -448,6 +448,10 @@ updateDocumentTitle(pageMeta)
max-width: unset;
}
.codex-editor__redactor {
padding-bottom: 0px !important;
}
.codeBoxHolder {
display: flex;
flex-direction: column;
@@ -537,4 +541,13 @@ updateDocumentTitle(pageMeta)
color: #383a42;
background-color: #fafafa;
}
.codeBoxTextArea {
line-height: 1.7;
}
iframe {
border-top: 3px solid theme('colors.gray.700');
border-bottom: 3px solid theme('colors.gray.700');
}
</style>

View File

@@ -62,7 +62,7 @@
</div>
<div class="">
<div class="sticky top-0 p-5">
<LessonPlugins :editor="editor" :notesEditor="instructorEditor" />
<LessonHelp />
</div>
</div>
</div>
@@ -70,16 +70,25 @@
</template>
<script setup>
import { Breadcrumbs, FormControl, createResource, Button } from 'frappe-ui'
import { computed, reactive, onMounted, inject, ref, watch } from 'vue'
import {
computed,
reactive,
onMounted,
inject,
ref,
onBeforeUnmount,
} from 'vue'
import EditorJS from '@editorjs/editorjs'
import LessonPlugins from '@/components/LessonPlugins.vue'
import LessonHelp from '@/components/LessonHelp.vue'
import { ChevronRight } from 'lucide-vue-next'
import { updateDocumentTitle, createToast, getEditorTools } from '@/utils'
import { capture } from '@/telemetry'
const editor = ref(null)
const instructorEditor = ref(null)
const user = inject('$user')
const openInstructorEditor = ref(false)
let autoSaveInterval
const props = defineProps({
courseName: {
@@ -100,6 +109,7 @@ onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
window.location.href = '/login'
}
capture('lesson_form_opened')
editor.value = renderEditor('content')
instructorEditor.value = renderEditor('instructor-notes')
})
@@ -107,7 +117,7 @@ onMounted(() => {
const renderEditor = (holder) => {
return new EditorJS({
holder: holder,
tools: getEditorTools(),
tools: getEditorTools(true),
autofocus: true,
})
}
@@ -133,33 +143,52 @@ const lessonDetails = createResource({
Object.keys(data.lesson).forEach((key) => {
lesson[key] = data.lesson[key]
})
lesson.include_in_preview = data.include_in_preview ? true : false
editor.value.isReady.then(() => {
if (data.lesson.content) {
editor.value.render(JSON.parse(data.lesson.content))
} else if (data.lesson.body) {
let blocks = convertToJSON(data.lesson)
editor.value.render({
blocks: blocks,
})
}
})
instructorEditor.value.isReady.then(() => {
if (data.lesson.instructor_content) {
instructorEditor.value.render(
JSON.parse(data.lesson.instructor_content)
)
} else if (data.lesson.instructor_notes) {
let blocks = convertToJSON(data.lesson)
instructorEditor.value.render({
blocks: blocks,
})
}
})
lesson.include_in_preview = data?.lesson?.include_in_preview
? true
: false
addLessonContent(data)
addInstructorNotes(data)
enableAutoSave()
}
},
})
const addLessonContent = (data) => {
editor.value.isReady.then(() => {
if (data.lesson.content) {
editor.value.render(JSON.parse(data.lesson.content))
} else if (data.lesson.body) {
let blocks = convertToJSON(data.lesson)
editor.value.render({
blocks: blocks,
})
}
})
}
const addInstructorNotes = (data) => {
instructorEditor.value.isReady.then(() => {
if (data.lesson.instructor_content) {
instructorEditor.value.render(JSON.parse(data.lesson.instructor_content))
} else if (data.lesson.instructor_notes) {
let blocks = convertToJSON(data.lesson)
instructorEditor.value.render({
blocks: blocks,
})
}
})
}
const enableAutoSave = () => {
autoSaveInterval = setInterval(() => {
saveLesson()
}, 10000)
}
onBeforeUnmount(() => {
clearInterval(autoSaveInterval)
})
const newLessonResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
@@ -335,6 +364,7 @@ const createNewLesson = () => {
{ lesson: data.name },
{
onSuccess() {
capture('lesson_created')
showToast('Success', 'Lesson created successfully', 'check')
lessonDetails.reload()
},
@@ -357,9 +387,6 @@ const editCurrentLesson = () => {
validate() {
return validateLesson()
},
onSuccess() {
showToast('Success', 'Lesson updated successfully', 'check')
},
onError(err) {
showToast('Error', err.message, 'x')
},
@@ -398,7 +425,7 @@ const breadcrumbs = computed(() => {
},
{
label: lessonDetails.data?.course_title,
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
route: { name: 'CourseForm', params: { courseName: props.courseName } },
},
]
@@ -418,7 +445,7 @@ const breadcrumbs = computed(() => {
crumbs.push({
label: lessonDetails?.data?.lesson ? 'Edit Lesson' : 'Create Lesson',
route: {
name: 'CreateLesson',
name: 'LessonForm',
params: {
courseName: props.courseName,
chapterNumber: props.chapterNumber,
@@ -448,6 +475,10 @@ updateDocumentTitle(pageMeta)
max-width: none;
}
.codex-editor--narrow .ce-toolbar__actions {
right: 100%;
}
.ce-toolbar__content {
max-width: none;
}
@@ -520,10 +551,6 @@ updateDocumentTitle(pageMeta)
cursor: pointer;
}
.codeBoxSelectItem:hover {
opacity: 0.7;
}
.codeBoxSelectedItem {
background-color: lightblue !important;
}
@@ -541,4 +568,17 @@ updateDocumentTitle(pageMeta)
color: #383a42;
background-color: #fafafa;
}
.codeBoxTextArea {
line-height: 1.7;
}
.prose :where(pre):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
overflow-x: unset;
}
iframe {
border-top: 3px solid theme('colors.gray.700');
border-bottom: 3px solid theme('colors.gray.700');
}
</style>

View File

@@ -146,7 +146,7 @@ const coverImage = createResource({
const setActiveTab = () => {
let fragments = route.path.split('/')
let sections = ['certificates', 'roles', 'evaluations']
let sections = ['certificates', 'roles', 'slots', 'schedule']
sections.forEach((section) => {
if (fragments.includes(section)) {
activeTab.value = convertToTitleCase(section)
@@ -161,7 +161,8 @@ watchEffect(() => {
About: { name: 'ProfileAbout' },
Certificates: { name: 'ProfileCertificates' },
Roles: { name: 'ProfileRoles' },
Evaluations: { name: 'ProfileEvaluator' },
Slots: { name: 'ProfileEvaluator' },
Schedule: { name: 'ProfileEvaluationSchedule' },
}[activeTab.value]
router.push(route)
}
@@ -185,8 +186,13 @@ const isSessionUser = () => {
const getTabButtons = () => {
let buttons = [{ label: 'About' }, { label: 'Certificates' }]
if ($user.data?.is_moderator) buttons.push({ label: 'Roles' })
if (isSessionUser() && $user.data?.is_evaluator)
buttons.push({ label: 'Evaluations' })
if (
isSessionUser() &&
($user.data?.is_evaluator || $user.data?.is_moderator)
) {
buttons.push({ label: 'Slots' })
buttons.push({ label: 'Schedule' })
}
return buttons
}

View File

@@ -16,7 +16,7 @@
<h2 class="mb-3 text-lg font-semibold text-gray-900">
{{ __('Achievements') }}
</h2>
<div class="grid grid-cols-5 gap-4">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<div v-for="badge in badges.data">
<Popover trigger="hover" :leaveDelay="Number(0.01)">
<template #target>

View File

@@ -0,0 +1,102 @@
<template>
<div class="mt-7 mb-20">
<div class="flex h-screen flex-col overflow-hidden">
<Calendar
v-if="evaluations.data?.length"
:config="{
defaultMode: 'Month',
disableModes: ['Day', 'Week'],
redundantCellHeight: 100,
enableShortcuts: false,
}"
:events="evaluations.data"
@click="(event) => openEvent(event)"
>
<template #header="{ currentMonthYear, decrement, increment }">
<div class="mb-2 flex justify-between">
<span class="text-lg font-semibold">
{{ currentMonthYear }}
</span>
<div class="flex gap-x-1">
<Button
@click="decrement()"
variant="ghost"
class="h-4 w-4"
icon="chevron-left"
/>
<Button
@click="increment()"
variant="ghost"
class="h-4 w-4"
icon="chevron-right"
/>
</div>
</div>
</template>
</Calendar>
</div>
</div>
<Event v-model="showEvent" :event="currentEvent" />
</template>
<script setup>
import { Calendar, createListResource, Button } from 'frappe-ui'
import { inject, ref } from 'vue'
import Event from '@/components/Modals/Event.vue'
const user = inject('$user')
const currentEvent = ref(null)
const showEvent = ref(false)
const props = defineProps({
profile: {
type: Object,
required: true,
},
})
const evaluations = createListResource({
doctype: 'LMS Certificate Request',
filters: {
evaluator: user.data?.name,
},
fields: [
'name',
'member_name',
'member',
'course',
'course_title',
'batch_name',
'batch_title',
'evaluator',
'evaluator_name',
'date',
'start_time',
'end_time',
'google_meet_link',
],
auto: true,
orderBy: 'creation desc',
limit: 100,
cache: ['schedule', user.data?.name],
transform(data) {
return data.map((d) => {
let mappedData = Object.assign({}, d)
mappedData.title = `${d.member_name}'s Evaluation`
mappedData.participant = d.member_name
mappedData.id = d.name
mappedData.venue = d.google_meet_link
mappedData.fromDate = `${d.date} ${d.start_time}`
mappedData.toDate = `${d.date} ${d.end_time}`
mappedData.color = 'green'
return mappedData
})
},
})
const openEvent = (event) => {
currentEvent.value = event.calendarEvent
showEvent.value = true
}
</script>

View File

@@ -1,209 +0,0 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
</header>
<div class="w-3/4 mx-auto py-5">
<!-- Details -->
<div class="mb-8">
<div class="text-sm font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="grid grid-cols-2 gap-5">
<div class="space-y-2">
<FormControl v-model="quiz.title" :label="__('Title')" />
<FormControl
v-model="quiz.max_attempts"
:label="__('Maximun Attempts')"
/>
<FormControl
v-model="quiz.limit_questions_to"
:label="__('Limit Questions To')"
/>
</div>
<div class="space-y-2">
<FormControl v-model="quiz.total_marks" :label="__('Total Marks')" />
<FormControl
v-model="quiz.passing_percentage"
:label="__('Passing Percentage')"
/>
</div>
</div>
</div>
<!-- Settings -->
<div class="mb-8">
<div class="text-sm font-semibold mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-3 gap-5 my-4">
<FormControl
v-model="quiz.show_answers"
type="checkbox"
:label="__('Show Answers')"
/>
<FormControl
v-model="quiz.show_submission_history"
type="checkbox"
:label="__('Show Submission History')"
/>
<FormControl
v-model="quiz.shuffle_questions"
type="checkbox"
:label="__('Shuffle Questions')"
/>
</div>
</div>
<!-- Questions -->
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-sm font-semibold">
{{ __('Questions') }}
</div>
<Button @click="openQuestionModal()">
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('New Question') }}
</Button>
</div>
<ListView
:columns="questionColumns"
:rows="quiz.questions"
row-key="name"
:options="{
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in questionColumns" />
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-slot="{ idx, column, item }"
v-for="row in quiz.questions"
@click="openQuestionModal(row.question)"
>
<ListRowItem :item="item">
<div
v-if="column.key == 'question_detail'"
class="text-xs truncate"
>
{{ item }}
</div>
<div v-else class="text-xs">
{{ item }}
</div>
</ListRowItem>
</ListRow>
</ListRows>
</ListView>
</div>
</div>
<Question v-model="showQuestionModal" :questionName="currentQuestion" />
</template>
<script setup>
import {
Breadcrumbs,
createDocumentResource,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
Button,
} from 'frappe-ui'
import { computed, reactive, ref } from 'vue'
import { Plus } from 'lucide-vue-next'
import Question from '@/components/Modals/Question.vue'
const showQuestionModal = ref(false)
const currentQuestion = ref(null)
const props = defineProps({
quizID: {
type: String,
required: true,
},
})
const quiz = reactive({
title: '',
total_marks: '',
passing_percentage: '',
max_attempts: 0,
limit_questions_to: 0,
show_answers: true,
show_submission_history: false,
shuffle_questions: false,
questions: [],
})
const quizDetails = createDocumentResource({
doctype: 'LMS Quiz',
name: props.quizID,
auto: true,
cache: ['quiz', props.quizID],
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (Object.hasOwn(quiz, key)) quiz[key] = data[key]
})
let checkboxes = [
'show_answers',
'show_submission_history',
'shuffle_questions',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
quiz[key] = quiz[key] ? true : false
}
},
})
const questionColumns = computed(() => {
return [
{
label: __('ID'),
key: 'question',
width: '25%',
},
{
label: __('Question'),
key: __('question_detail'),
width: '60%',
},
{
label: __('Marks'),
key: 'marks',
width: '10%',
},
]
})
const openQuestionModal = (question = null) => {
console.log('called')
console.log(question)
currentQuestion.value = question
showQuestionModal.value = true
}
const breadcrumbs = computed(() => {
let crumbs = [
{
label: __('Quizzes'),
route: {
name: 'Quizzes',
},
},
]
return crumbs
})
</script>

View File

@@ -0,0 +1,432 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<Button variant="solid" @click="submitQuiz()">
{{ __('Save') }}
</Button>
</header>
<div class="w-3/4 mx-auto py-5">
<!-- Details -->
<div class="mb-8">
<div class="text-sm font-semibold mb-4">
{{ __('Details') }}
</div>
<FormControl
v-model="quiz.title"
:label="
quizDetails.data?.name
? __('Title')
: __('Enter a title and save the quiz to proceed')
"
/>
<div v-if="quizDetails.data?.name">
<div class="grid grid-cols-3 gap-5 mt-4 mb-8">
<FormControl
v-model="quiz.max_attempts"
:label="__('Maximun Attempts')"
/>
<FormControl
v-model="quiz.total_marks"
:label="__('Total Marks')"
disabled
/>
<FormControl
v-model="quiz.passing_percentage"
:label="__('Passing Percentage')"
/>
</div>
<!-- Settings -->
<div class="mb-8">
<div class="text-sm font-semibold mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-3 gap-5 my-4">
<FormControl
v-model="quiz.show_answers"
type="checkbox"
:label="__('Show Answers')"
/>
<FormControl
v-model="quiz.show_submission_history"
type="checkbox"
:label="__('Show Submission History')"
/>
</div>
</div>
<div class="mb-8">
<div class="text-sm font-semibold mb-4">
{{ __('Shuffle Settings') }}
</div>
<div class="grid grid-cols-3">
<FormControl
v-model="quiz.shuffle_questions"
type="checkbox"
:label="__('Shuffle Questions')"
/>
<FormControl
v-if="quiz.shuffle_questions"
v-model="quiz.limit_questions_to"
:label="__('Limit Questions To')"
/>
</div>
</div>
<!-- Questions -->
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-sm font-semibold">
{{ __('Questions') }}
</div>
<Button @click="openQuestionModal()">
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('New Question') }}
</Button>
</div>
<ListView
:columns="questionColumns"
:rows="quiz.questions"
row-key="name"
:options="{
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in questionColumns" />
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-slot="{ idx, column, item }"
v-for="row in quiz.questions"
@click="openQuestionModal(row)"
>
<ListRowItem :item="item">
<div
v-if="column.key == 'question_detail'"
class="text-xs truncate h-4"
v-html="item"
></div>
<div v-else class="text-xs">
{{ item }}
</div>
</ListRowItem>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="deleteQuestions(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
</div>
</div>
</div>
<Question
v-model="showQuestionModal"
:questionDetail="currentQuestion"
v-model:quiz="quizDetails"
:title="
currentQuestion.question
? __('Edit the question')
: __('Add a new question')
"
/>
</template>
<script setup>
import {
Breadcrumbs,
createResource,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
Button,
} from 'frappe-ui'
import {
computed,
reactive,
ref,
onMounted,
inject,
onBeforeUnmount,
watch,
isReactive,
} from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import Question from '@/components/Modals/Question.vue'
import { showToast, updateDocumentTitle } from '@/utils'
import { useRouter } from 'vue-router'
const showQuestionModal = ref(false)
const currentQuestion = reactive({
question: '',
marks: 0,
name: '',
})
const user = inject('$user')
const router = useRouter()
const props = defineProps({
quizID: {
type: String,
required: true,
},
})
const quiz = reactive({
title: '',
total_marks: 0,
passing_percentage: 0,
max_attempts: 0,
limit_questions_to: 0,
show_answers: true,
show_submission_history: false,
shuffle_questions: false,
questions: [],
})
onMounted(() => {
if (
props.quizID == 'new' &&
!user.data?.is_moderator &&
!user.data?.is_instructor
) {
router.push({ name: 'Courses' })
}
if (props.quizID !== 'new') {
quizDetails.reload()
}
window.addEventListener('keydown', keyboardShortcut)
})
const keyboardShortcut = (e) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
!e.target.classList.contains('ProseMirror')
) {
submitQuiz()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
watch(
() => props.quizID !== 'new',
(newVal) => {
if (newVal) {
quizDetails.reload()
}
}
)
const quizDetails = createResource({
url: 'frappe.client.get',
makeParams(values) {
return { doctype: 'LMS Quiz', name: props.quizID }
},
auto: false,
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (Object.hasOwn(quiz, key)) quiz[key] = data[key]
})
let checkboxes = [
'show_answers',
'show_submission_history',
'shuffle_questions',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
quiz[key] = quiz[key] ? true : false
}
},
})
const quizCreate = createResource({
url: 'frappe.client.insert',
auto: false,
makeParams(values) {
return {
doc: {
doctype: 'LMS Quiz',
...quiz,
},
}
},
})
const quizUpdate = createResource({
url: 'frappe.client.set_value',
auto: false,
makeParams(values) {
return {
doctype: 'LMS Quiz',
name: values.quizID,
fieldname: {
total_marks: calculateTotalMarks(),
...quiz,
},
}
},
})
const submitQuiz = () => {
if (quizDetails.data?.name) updateQuiz()
else createQuiz()
}
const createQuiz = () => {
quizCreate.submit(
{},
{
onSuccess(data) {
showToast(__('Success'), __('Quiz created successfully'), 'check')
router.push({
name: 'QuizForm',
params: { quizID: data.name },
})
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
const updateQuiz = () => {
quizUpdate.submit(
{ quizID: quizDetails.data?.name },
{
onSuccess(data) {
quiz.total_marks = data.total_marks
showToast(__('Success'), __('Quiz updated successfully'), 'check')
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
const calculateTotalMarks = () => {
let totalMarks = 0
if (quiz.limit_questions_to && quiz.questions.length > 0)
return quiz.questions[0].marks * quiz.limit_questions_to
quiz.questions.forEach((question) => {
totalMarks += question.marks
})
return totalMarks
}
const questionColumns = computed(() => {
return [
{
label: __('ID'),
key: 'question',
width: '25%',
},
{
label: __('Question'),
key: __('question_detail'),
width: '60%',
},
{
label: __('Marks'),
key: 'marks',
width: '10%',
},
]
})
const openQuestionModal = (question = null) => {
if (question) {
currentQuestion.question = question.question
currentQuestion.marks = question.marks
currentQuestion.name = question.name
} else {
currentQuestion.question = ''
currentQuestion.marks = 0
currentQuestion.name = ''
}
showQuestionModal.value = true
}
const deleteQuestionResource = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'LMS Quiz Question',
documents: values.questions,
}
},
})
const deleteQuestions = (selections, unselectAll) => {
deleteQuestionResource.submit(
{
questions: Array.from(selections),
},
{
onSuccess() {
showToast(__('Success'), __('Questions deleted successfully'), 'check')
quizDetails.reload()
unselectAll()
},
}
)
}
const breadcrumbs = computed(() => {
let crumbs = [
{
label: __('Quizzes'),
route: {
name: 'Quizzes',
},
},
]
/* if (quizDetails.data) {
crumbs.push({
label: quiz.title,
})
} */
crumbs.push({
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
route: { name: 'QuizForm', params: { quizID: props.quizID } },
})
return crumbs
})
const pageMeta = computed(() => {
return {
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
description: __('Form to create and edit quizzes'),
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -0,0 +1,48 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
</header>
<div class="w-1/2 mx-auto py-10">
<Quiz :quizName="quizID" />
</div>
</template>
<script setup>
import Quiz from '@/components/Quiz.vue'
import { createResource, Breadcrumbs } from 'frappe-ui'
import { computed, inject, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const user = inject('$user')
const router = useRouter()
onMounted(() => {
if (!user.data) {
router.push({ name: 'Courses' })
}
})
const props = defineProps({
quizID: {
type: String,
required: true,
},
})
const title = createResource({
url: 'frappe.client.get_value',
params: {
doctype: 'LMS Quiz',
fieldname: 'title',
filters: {
name: props.quizID,
},
},
auto: true,
})
const breadcrumbs = computed(() => {
return [{ label: __('Quiz Submission') }, { label: title.data?.title }]
})
</script>

View File

@@ -3,12 +3,21 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<Button variant="solid">
<template #prefix>
<Plus class="w-4 h-4"/>
</template>
{{ __('New Quiz') }}
</Button>
<router-link
:to="{
name: 'QuizForm',
params: {
quizID: 'new',
},
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="w-4 h-4" />
</template>
{{ __('New Quiz') }}
</Button>
</router-link>
</header>
<div v-if="quizzes.data?.length" class="w-3/4 mx-auto py-5">
<ListView
@@ -27,7 +36,7 @@
<router-link
v-for="row in quizzes.data"
:to="{
name: 'QuizCreation',
name: 'QuizForm',
params: {
quizID: row.name,
},
@@ -50,10 +59,19 @@ import {
ListHeaderItem,
Button,
} from 'frappe-ui'
import { computed, inject } from 'vue'
import { useRouter } from 'vue-router'
import { computed, inject, onMounted } from 'vue'
import { Plus } from 'lucide-vue-next'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const router = useRouter()
onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' })
}
})
const quizFilter = computed(() => {
if (user.data?.is_moderator) return {}
@@ -68,6 +86,7 @@ const quizzes = createListResource({
fields: ['name', 'title', 'passing_percentage', 'total_marks'],
auto: true,
cache: ['quizzes', user.data?.name],
orderBy: 'modified desc',
onSuccess(data) {
data.forEach((row) => {})
},
@@ -105,4 +124,13 @@ const breadcrumbs = computed(() => {
},
]
})
const pageMeta = computed(() => {
return {
title: __('Quizzes'),
description: __('List of quizzes'),
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -79,9 +79,15 @@ const routes = [
},
{
name: 'ProfileEvaluator',
path: 'evaluations',
path: 'slots',
component: () => import('@/pages/ProfileEvaluator.vue'),
},
{
name: 'ProfileEvaluationSchedule',
path: 'schedule',
component: () =>
import('@/pages/ProfileEvaluationSchedule.vue'),
},
],
},
{
@@ -97,20 +103,20 @@ const routes = [
},
{
path: '/courses/:courseName/edit',
name: 'CreateCourse',
component: () => import('@/pages/CreateCourse.vue'),
name: 'CourseForm',
component: () => import('@/pages/CourseForm.vue'),
props: true,
},
{
path: '/courses/:courseName/learn/:chapterNumber-:lessonNumber/edit',
name: 'CreateLesson',
component: () => import('@/pages/CreateLesson.vue'),
name: 'LessonForm',
component: () => import('@/pages/LessonForm.vue'),
props: true,
},
{
path: '/batches/:batchName/edit',
name: 'BatchCreation',
component: () => import('@/pages/BatchCreation.vue'),
name: 'BatchForm',
component: () => import('@/pages/BatchForm.vue'),
props: true,
},
{
@@ -148,8 +154,14 @@ const routes = [
},
{
path: '/quizzes/:quizID',
name: 'QuizCreation',
component: () => import('@/pages/QuizCreation.vue'),
name: 'QuizForm',
component: () => import('@/pages/QuizForm.vue'),
props: true,
},
{
path: '/quiz/:quizID',
name: 'Quiz',
component: () => import('@/pages/QuizSubmission.vue'),
props: true,
},
]

View File

@@ -53,11 +53,18 @@ export const sessionStore = defineStore('lms-session', () => {
},
})
const sidebarSettings = createResource({
url: 'lms.lms.api.get_sidebar_settings',
cache: 'Sidebar Settings',
auto: false,
})
return {
user,
isLoggedIn,
login,
logout,
branding,
sidebarSettings,
}
})

98
frontend/src/telemetry.ts Normal file
View File

@@ -0,0 +1,98 @@
import { useStorage } from "@vueuse/core";
import { call } from "frappe-ui";
import "../../../frappe/frappe/public/js/lib/posthog.js";
const APP = "lms";
const SITENAME = window.location.hostname;
declare global {
interface Window {
posthog: any;
}
}
const telemetry = useStorage("telemetry", {
enabled: false,
project_id: "",
host: "",
});
export async function init() {
await set_enabled();
if (!telemetry.value.enabled) return;
try {
await set_credentials();
window.posthog.init(telemetry.value.project_id, {
api_host: telemetry.value.host,
autocapture: false,
person_profiles: "always",
capture_pageview: true,
capture_pageleave: true,
disable_session_recording: false,
session_recording: {
maskAllInputs: false,
maskInputOptions: {
password: true,
},
},
loaded: (posthog) => {
window.posthog = posthog;
window.posthog.identify(SITENAME);
},
});
} catch (e) {
console.trace("Failed to initialize telemetry", e);
telemetry.value.enabled = false;
}
}
async function set_enabled() {
if (telemetry.value.enabled) return;
await call("lms.lms.telemetry.is_enabled").then((res) => {
telemetry.value.enabled = res;
});
}
async function set_credentials() {
if (!telemetry.value.enabled) return;
if (telemetry.value.project_id && telemetry.value.host) return;
await call("lms.lms.telemetry.get_credentials").then((res) => {
telemetry.value.project_id = res.project_id;
telemetry.value.host = res.telemetry_host;
});
}
interface CaptureOptions {
data: {
user: string;
[key: string]: string | number | boolean | object;
};
}
export function capture(
event: string,
options: CaptureOptions = { data: { user: "" } }
) {
if (!telemetry.value.enabled) return;
window.posthog.capture(`${APP}_${event}`, options);
}
export function recordSession() {
if (!telemetry.value.enabled) return;
if (window.posthog && window.posthog.__loaded) {
window.posthog.startSessionRecording();
}
}
export function stopSession() {
if (!telemetry.value.enabled) return;
if (
window.posthog &&
window.posthog.__loaded &&
window.posthog.sessionRecordingStarted()
) {
window.posthog.stopSessionRecording();
}
}

View File

@@ -62,7 +62,7 @@ export class CodeBox {
static get toolbox() {
const app = createApp({
render: () => h(Code, { size: 24, strokeWidth: 2, color: 'black' }),
render: () => h(Code, { size: 18, strokeWidth: 1.5, color: 'black' }),
});
const div = document.createElement('div');

View File

@@ -1,32 +0,0 @@
import Embed from '@editorjs/embed'
import VideoBlock from '@/components/VideoBlock.vue'
import { createApp } from 'vue'
export class CustomEmbed extends Embed {
render() {
const container = super.render()
const { service, source, embed } = this.data
if (service === 'youtube' || service === 'vimeo') {
// Remove the iframe or existing embed content
container.innerHTML = ''
// Create a placeholder element for Vue component
const vueContainer = document.createElement('div')
vueContainer.setAttribute('data-service', service)
vueContainer.setAttribute('data-video-id', this.data.source)
// Append the Vue placeholder
container.appendChild(vueContainer)
console.log(source)
// Mount the Vue component (using a global Vue instance)
const app = createApp(VideoBlock, {
file: source,
type: 'video/youtube',
})
app.mount(vueContainer)
}
return container
}
}

View File

@@ -149,9 +149,9 @@ export function getEditorTools() {
class: CodeBox,
config: {
themeURL:
'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/dracula.min.css', // Optional
themeName: 'atom-one-dark', // Optional
useDefaultTheme: 'dark', // Optional. This also determines the background color of the language select drop-down
'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/atom-one-dark.min.css',
themeName: 'atom-one-dark',
useDefaultTheme: 'dark',
},
},
list: {
@@ -233,10 +233,10 @@ export function getEditorTools() {
},
github: true,
slides: {
regex: /https:\/\/docs\.google\.com\/presentation\/d\/e\/([A-Za-z0-9_-]+)\/pub/,
regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/pub/,
embedUrl:
'https://docs.google.com/presentation/d/e/<%= remote_id %>/embed',
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
'https://docs.google.com/presentation/d/<%= remote_id %>/embed',
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px; margin: 1rem 0' frameborder='0' allowfullscreen='true'></iframe>",
},
drive: {
regex: /https:\/\/drive\.google\.com\/file\/d\/([A-Za-z0-9_-]+)\/view(\?.+)?/,
@@ -260,7 +260,7 @@ export function getEditorTools() {
regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
embedUrl:
'https://docs.google.com/presentation/d/<%= remote_id %>/embed',
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px; margin: 1rem 0;' frameborder='0' allowfullscreen='true'></iframe>",
},
codesandbox: {
regex: /^https:\/\/codesandbox\.io\/(?:embed\/)?([A-Za-z0-9_-]+)(?:\?[^\/]*)?$/,
@@ -424,15 +424,15 @@ export function getSidebarLinks() {
'Courses',
'CourseDetail',
'Lesson',
'CreateCourse',
'CreateLesson',
'CourseForm',
'LessonForm',
],
},
{
label: 'Batches',
icon: 'Users',
to: 'Batches',
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchCreation'],
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
},
{
label: 'Certified Participants',
@@ -483,3 +483,19 @@ export function getLineStartPosition(string, position) {
return position
}
export function singularize(word) {
const endings = {
ves: 'fe',
ies: 'y',
i: 'us',
zes: 'ze',
ses: 's',
es: 'e',
s: '',
}
return word.replace(
new RegExp(`(${Object.keys(endings).join('|')})$`),
(r) => endings[r]
)
}

View File

@@ -1,7 +1,9 @@
import QuizBlock from '@/components/QuizBlock.vue'
import { createApp } from 'vue'
import QuizPlugin from '@/components/QuizPlugin.vue'
import { createApp, h } from 'vue'
import { usersStore } from '../stores/user'
import translationPlugin from '../translation'
import { CircleHelp } from 'lucide-vue-next'
export class Quiz {
constructor({ data, api, readOnly }) {
@@ -9,17 +11,31 @@ export class Quiz {
this.readOnly = readOnly
}
static get toolbox() {
const app = createApp({
render: () =>
h(CircleHelp, { size: 18, strokeWidth: 1.5, color: 'black' }),
})
const div = document.createElement('div')
app.mount(div)
return {
title: __('Quiz'),
icon: div.innerHTML,
}
}
static get isReadOnlySupported() {
return true
}
render() {
this.wrapper = document.createElement('div')
if (this.data) {
let renderedQuiz = this.renderQuiz(this.data.quiz)
if (!this.readOnly) {
this.wrapper.innerHTML = renderedQuiz
}
if (Object.keys(this.data).length) {
this.renderQuiz(this.data.quiz)
} else {
this.renderQuizModal()
}
return this.wrapper
}
@@ -27,7 +43,7 @@ export class Quiz {
renderQuiz(quiz) {
if (this.readOnly) {
const app = createApp(QuizBlock, {
quiz: quiz, // Pass quiz content as prop
quiz: quiz,
})
app.use(translationPlugin)
const { userResource } = usersStore()
@@ -35,11 +51,23 @@ export class Quiz {
app.mount(this.wrapper)
return
}
return `<div class='border rounded-md p-10 text-center mb-2'>
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center mb-2'>
<span class="font-medium">
Quiz: ${quiz}
</span>
</div>`
return
}
renderQuizModal() {
const app = createApp(QuizPlugin, {
onQuizAddition: (quiz) => {
this.data.quiz = quiz
this.renderQuiz(quiz)
},
})
app.use(translationPlugin)
app.mount(this.wrapper)
}
save(blockContent) {

View File

@@ -1,6 +1,9 @@
import AudioBlock from '@/components/AudioBlock.vue'
import VideoBlock from '@/components/VideoBlock.vue'
import { createApp } from 'vue'
import UploadPlugin from '@/components/UploadPlugin.vue'
import { h, createApp } from 'vue'
import { Upload as UploadIcon } from 'lucide-vue-next'
import translationPlugin from '../translation'
export class Upload {
constructor({ data, api, readOnly }) {
@@ -8,17 +11,38 @@ export class Upload {
this.readOnly = readOnly
}
static get toolbox() {
const app = createApp({
render: () =>
h(UploadIcon, { size: 18, strokeWidth: 1.5, color: 'black' }),
})
const div = document.createElement('div')
app.mount(div)
return {
title: 'Upload',
icon: div.innerHTML,
}
}
static get isReadOnlySupported() {
return true
}
render() {
this.wrapper = document.createElement('div')
this.renderUpload(this.data)
if (this.data && this.data.file_url) {
this.renderFile(this.data)
} else {
this.renderFileUploader()
}
return this.wrapper
}
renderUpload(file) {
renderFile(file) {
if (this.isVideo(file.file_type)) {
const app = createApp(VideoBlock, {
file: file.file_url,
@@ -44,6 +68,25 @@ export class Upload {
}
}
renderFileUploader() {
const app = createApp(UploadPlugin, {
onFileUploaded: (file) => {
this.data.file_url = file.file_url
this.data.file_type = file.file_type
this.renderFile(file)
},
})
app.use(translationPlugin)
app.mount(this.wrapper)
}
validate(savedData) {
if (!savedData.file_url || !savedData.file_type) {
return false
}
return true
}
save(blockContent) {
return {
file_url: this.data.file_url,

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
__version__ = "2.0.0"
__version__ = "2.5.0"

View File

@@ -4,9 +4,11 @@ app_name = "frappe_lms"
app_title = "Frappe LMS"
app_publisher = "Frappe"
app_description = "Frappe LMS App"
app_icon = "octicon octicon-file-directory"
app_icon_url = "/assets/lms/images/lms-logo.png"
app_icon_title = "Learning"
app_icon_route = "/lms"
app_color = "grey"
app_email = "school@frappe.io"
app_email = "jannat@frappe.io"
app_license = "AGPL"
# Includes in <head>
@@ -61,8 +63,6 @@ web_include_js = ["website.bundle.js"]
after_install = "lms.install.after_install"
after_sync = "lms.install.after_sync"
before_uninstall = "lms.install.before_uninstall"
setup_wizard_requires = "assets/lms/js/setup_wizard.js"
# Desk Notifications
@@ -176,7 +176,16 @@ update_website_context = [
]
jinja = {
"methods": ["lms.lms.utils.get_signup_optin_checks"],
"methods": [
"lms.lms.utils.get_signup_optin_checks",
"lms.lms.utils.get_tags",
"lms.lms.utils.get_lesson_count",
"lms.lms.utils.get_instructors",
"lms.lms.utils.get_lesson_index",
"lms.lms.utils.get_lesson_url",
"lms.page_renderers.get_profile_url",
"lms.overrides.user.get_palette",
],
"filters": [],
}
## Specify the additional tabs to be included in the user profile page.
@@ -222,3 +231,13 @@ profile_url_prefix = "/users/"
signup_form_template = "lms.plugins.show_custom_signup"
on_session_creation = "lms.overrides.user.on_session_creation"
add_to_apps_screen = [
{
"name": "lms",
"logo": "/assets/lms/images/lms-logo.png",
"title": "Learning",
"route": "/lms",
"has_permission": "lms.lms.api.check_app_permission",
}
]

View File

@@ -6,6 +6,8 @@ from frappe.translate import get_all_translations
from frappe import _
from frappe.query_builder import DocType
from frappe.query_builder.functions import Count
from frappe.utils import time_diff, now_datetime, get_datetime
from typing import Optional
@frappe.whitelist()
@@ -265,7 +267,9 @@ def get_chart_details():
"upcoming": 0,
},
)
details.users = frappe.db.count("User", {"enabled": 1})
details.users = frappe.db.count(
"User", {"enabled": 1, "name": ["not in", ("Administrator", "Guest")]}
)
details.completions = frappe.db.count(
"LMS Enrollment", {"progress": ["like", "%100%"]}
)
@@ -559,3 +563,146 @@ def get_categories(doctype, filters):
categoryOptions.append({"label": category, "value": category})
return categoryOptions
@frappe.whitelist()
def get_members(start=0, search=""):
"""Get members for the given search term and start index.
Args: start (int): Start index for the query.
search (str): Search term to filter the results.
Returns: List of members.
"""
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
if search:
filters["full_name"] = ["like", f"%{search}%"]
members = frappe.get_all(
"User",
filters=filters,
fields=["name", "full_name", "user_image", "username", "last_active"],
page_length=20,
start=start,
)
for member in members:
roles = frappe.get_roles(member.name)
if "Moderator" in roles:
member.role = "Moderator"
elif "Course Creator" in roles:
member.role = "Course Creator"
elif "Batch Evaluator" in roles:
member.role = "Batch Evaluator"
elif "LMS Student" in roles:
member.role = "LMS Student"
return members
def check_app_permission():
"""Check if the user has permission to access the app."""
if frappe.session.user == "Administrator":
return True
roles = frappe.get_roles()
lms_roles = ["Moderator", "Course Creator", "Batch Evaluator", "LMS Student"]
if any(role in roles for role in lms_roles):
return True
return False
@frappe.whitelist()
def save_evaluation_details(
member,
course,
batch_name,
evaluator,
date,
start_time,
end_time,
status,
rating,
summary,
):
"""
Save evaluation details for a member against a course.
"""
evaluation = frappe.db.exists(
"LMS Certificate Evaluation", {"member": member, "course": course}
)
details = {
"date": date,
"start_time": start_time,
"end_time": end_time,
"status": status,
"rating": rating / 5,
"summary": summary,
"batch_name": batch_name,
}
if evaluation:
frappe.db.set_value("LMS Certificate Evaluation", evaluation, details)
return evaluation
else:
doc = frappe.new_doc("LMS Certificate Evaluation")
details.update(
{
"member": member,
"course": course,
"evaluator": evaluator,
}
)
doc.update(details)
doc.insert()
return doc.name
@frappe.whitelist()
def save_certificate_details(
member,
course,
batch_name,
evaluator,
issue_date,
expiry_date,
template,
published=True,
):
"""
Save certificate details for a member against a course.
"""
certificate = frappe.db.exists("LMS Certificate", {"member": member, "course": course})
details = {
"published": published,
"issue_date": issue_date,
"expiry_date": expiry_date,
"template": template,
"batch_name": batch_name,
}
if certificate:
frappe.db.set_value("LMS Certificate", certificate, details)
return certificate
else:
doc = frappe.new_doc("LMS Certificate")
details.update(
{
"member": member,
"course": course,
"evaluator": evaluator,
}
)
doc.update(details)
doc.insert()
return doc.name
@frappe.whitelist()
def delete_documents(doctype, documents):
frappe.only_for("Moderator")
for doc in documents:
frappe.delete_doc(doctype, doc)

View File

@@ -7,5 +7,4 @@ from frappe.utils.telemetry import capture
class CourseChapter(Document):
def after_insert(self):
capture("chapter_created", "lms")
pass

View File

@@ -24,9 +24,6 @@ class CourseLesson(Document):
for section in dynamic_documents:
self.update_lesson_name_in_document(section)
def after_insert(self):
capture("lesson_created", "lms")
def update_lesson_name_in_document(self, section):
doctype_map = {"Exercise": "LMS Exercise", "Quiz": "LMS Quiz"}
macros = find_macros(self.body)
@@ -116,6 +113,8 @@ def save_progress(lesson, course):
).save(ignore_permissions=True)
progress = get_course_progress(course)
capture_progress_for_analytics(progress, course)
# Had to get doc, as on_change doesn't trigger when you use set_value. The trigger is necesary for badge to get assigned.
enrollment = frappe.get_doc("LMS Enrollment", membership)
enrollment.progress = progress
@@ -125,6 +124,11 @@ def save_progress(lesson, course):
return progress
def capture_progress_for_analytics(progress, course):
if progress in [25, 50, 75, 100]:
capture("course_progress", "lms", {"course": course, "progress": progress})
def get_quiz_progress(lesson):
lesson_details = frappe.db.get_value(
"Course Lesson", lesson, ["body", "content"], as_dict=1

View File

@@ -11,74 +11,4 @@ from lms.lms.doctype.invite_request.invite_request import (
class TestInviteRequest(unittest.TestCase):
@classmethod
def setUpClass(self):
create_invite_request("test_invite@example.com")
def test_create_invite_request(self):
if frappe.db.exists("Invite Request", {"invite_email": "test_invite@example.com"}):
invite = frappe.db.get_value(
"Invite Request",
filters={"invite_email": "test_invite@example.com"},
fieldname=["invite_email", "status", "signup_email"],
as_dict=True,
)
self.assertEqual(invite.status, "Approved")
self.assertEqual(invite.signup_email, None)
def test_create_invite_request_update(self):
if frappe.db.exists("Invite Request", {"invite_email": "test_invite@example.com"}):
data = {
"signup_email": "test_invite@example.com",
"username": "test_invite",
"full_name": "Test Invite",
"password": "Test@invite",
"invite_code": frappe.db.get_value(
"Invite Request", {"invite_email": "test_invite@example.com"}, "name"
),
}
update_invite(data)
invite = frappe.db.get_value(
"Invite Request",
filters={"invite_email": "test_invite@example.com"},
fieldname=[
"invite_email",
"status",
"signup_email",
"full_name",
"username",
"invite_code",
"name",
],
as_dict=True,
)
self.assertEqual(invite.signup_email, "test_invite@example.com")
self.assertEqual(invite.full_name, "Test Invite")
self.assertEqual(invite.username, "test_invite")
self.assertEqual(invite.invite_code, invite.name)
self.assertEqual(invite.status, "Registered")
user = frappe.db.get_value(
"User",
"test_invite@example.com",
fieldname=["first_name", "username", "send_welcome_email", "user_type"],
as_dict=True,
)
self.assertTrue(user)
self.assertEqual(user.first_name, invite.full_name.split(" ")[0])
self.assertEqual(user.username, invite.username)
self.assertEqual(user.send_welcome_email, 0)
self.assertEqual(user.user_type, "Website User")
@classmethod
def tearDownClass(self):
if frappe.db.exists("User", "test_invite@example.com"):
frappe.delete_doc("User", "test_invite@example.com")
invite_request = frappe.db.exists(
"Invite Request", {"invite_email": "test_invite@example.com"}
)
if invite_request:
frappe.delete_doc("Invite Request", invite_request)
pass

View File

@@ -8,12 +8,7 @@ import json
from frappe import _
from datetime import timedelta
from frappe.model.document import Document
from frappe.utils import (
cint,
format_date,
format_datetime,
get_time,
)
from frappe.utils import cint, format_date, format_datetime, get_time, getdate, add_days
from lms.lms.utils import (
get_lessons,
get_lesson_index,
@@ -73,21 +68,23 @@ class LMSBatch(Document):
)
)
def validate_evaluation_end_date(self):
if self.evaluation_end_date and self.evaluation_end_date < self.end_date:
frappe.throw(_("Evaluation end date cannot be less than the batch end date."))
def send_confirmation_mail(self):
for student in self.students:
outgoing_email_account = frappe.get_cached_value(
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
)
if not student.confirmation_email_sent and (
outgoing_email_account or frappe.conf.get("mail_login")
if (
not student.confirmation_email_sent
and getdate(student.creation) >= add_days(getdate(), -2)
and (outgoing_email_account or frappe.conf.get("mail_login"))
):
self.send_mail(student)
student.confirmation_email_sent = 1
def validate_evaluation_end_date(self):
if self.evaluation_end_date and self.evaluation_end_date < self.end_date:
frappe.throw(_("Evaluation end date cannot be less than the batch end date."))
def send_mail(self, student):
subject = _("Enrollment Confirmation for the Next Training Batch")
template = "batch_confirmation"

View File

@@ -15,8 +15,10 @@
"template",
"published",
"section_break_scyf",
"expiry_date",
"evaluator",
"evaluator_name",
"column_break_slaw",
"expiry_date",
"batch_name"
],
"fields": [
@@ -95,11 +97,24 @@
{
"fieldname": "column_break_slaw",
"fieldtype": "Column Break"
},
{
"fieldname": "evaluator",
"fieldtype": "Link",
"label": "Evaluator",
"options": "User"
},
{
"fetch_from": "evaluator.full_name",
"fieldname": "evaluator_name",
"fieldtype": "Data",
"label": "Evaluator Name",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-07-16 15:29:19.708888",
"modified": "2024-09-11 11:37:20.419955",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Certificate",

View File

@@ -8,12 +8,16 @@
"field_order": [
"member",
"member_name",
"column_break_ueht",
"course",
"batch_name",
"section_break_zwfi",
"evaluator",
"evaluator_name",
"column_break_5",
"date",
"start_time",
"end_time",
"batch_name",
"section_break_6",
"rating",
"status",
@@ -103,11 +107,33 @@
"in_standard_filter": 1,
"label": "Batch Name",
"options": "LMS Batch"
},
{
"fieldname": "column_break_ueht",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_zwfi",
"fieldtype": "Section Break"
},
{
"fieldname": "evaluator",
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Evaluator",
"options": "User"
},
{
"fetch_from": "evaluator.full_name",
"fieldname": "evaluator_name",
"fieldtype": "Data",
"label": "Evaluator Name",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-07-16 14:06:11.977666",
"modified": "2024-09-11 11:20:06.233491",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Certificate Evaluation",

View File

@@ -7,11 +7,17 @@
"engine": "InnoDB",
"field_order": [
"course",
"evaluator",
"batch_name",
"course_title",
"column_break_4",
"member",
"member_name",
"section_break_ikne",
"evaluator",
"evaluator_name",
"column_break_sjco",
"batch_name",
"batch_title",
"timezone",
"section_break_lifi",
"date",
"day",
@@ -33,7 +39,6 @@
{
"fieldname": "member",
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Member",
"options": "User",
"reqd": 1
@@ -41,9 +46,9 @@
{
"fieldname": "evaluator",
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Evaluator",
"options": "User",
"read_only": 1
"options": "User"
},
{
"fieldname": "date",
@@ -103,11 +108,47 @@
"in_standard_filter": 1,
"label": "Batch",
"options": "LMS Batch"
},
{
"fieldname": "section_break_ikne",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_sjco",
"fieldtype": "Column Break"
},
{
"fetch_from": "course.title",
"fieldname": "course_title",
"fieldtype": "Data",
"label": "Course Title",
"read_only": 1
},
{
"fetch_from": "evaluator.full_name",
"fieldname": "evaluator_name",
"fieldtype": "Data",
"label": "Evaluator Name",
"read_only": 1
},
{
"fetch_from": "batch_name.timezone",
"fieldname": "timezone",
"fieldtype": "Data",
"label": "Timezone",
"read_only": 1
},
{
"fetch_from": "batch_name.title",
"fieldname": "batch_title",
"fieldtype": "Data",
"hidden": 1,
"label": "Batch Title"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-04-16 11:01:28.336807",
"modified": "2024-09-11 11:19:44.669132",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Certificate Request",

View File

@@ -34,23 +34,27 @@ class LMSCertificateRequest(Document):
self.evaluator = get_evaluator(self.course, self.batch_name)
def validate_unavailability(self):
unavailable = frappe.db.get_value(
"Course Evaluator", self.evaluator, ["unavailable_from", "unavailable_to"], as_dict=1
)
if (
unavailable.unavailable_from
and unavailable.unavailable_to
and getdate(self.date) >= unavailable.unavailable_from
and getdate(self.date) <= unavailable.unavailable_to
):
frappe.throw(
_(
"The evaluator of this course is unavailable from {0} to {1}. Please select a date after {1}"
).format(
format_date(unavailable.unavailable_from, "medium"),
format_date(unavailable.unavailable_to, "medium"),
)
if self.evaluator:
unavailable = frappe.db.get_value(
"Course Evaluator",
self.evaluator,
["unavailable_from", "unavailable_to"],
as_dict=1,
)
if (
unavailable.unavailable_from
and unavailable.unavailable_to
and getdate(self.date) >= unavailable.unavailable_from
and getdate(self.date) <= unavailable.unavailable_to
):
frappe.throw(
_(
"The evaluator of this course is unavailable from {0} to {1}. Please select a date after {1}"
).format(
format_date(unavailable.unavailable_from, "medium"),
format_date(unavailable.unavailable_to, "medium"),
)
)
def validate_slot(self):
if frappe.db.exists(
@@ -59,6 +63,7 @@ class LMSCertificateRequest(Document):
"evaluator": self.evaluator,
"date": self.date,
"start_time": self.start_time,
"member": ["!=", self.member],
},
):
frappe.throw(_("The slot is already booked by another participant."))
@@ -119,14 +124,12 @@ class LMSCertificateRequest(Document):
template = "certificate_request_notification"
args = {
"course": frappe.db.get_value("LMS Course", self.course, "title"),
"timezone": frappe.db.get_value("LMS Batch", self.batch_name, "timezone")
if self.batch_name
else "",
"course": self.course_title,
"timezone": self.timezone if self.batch_name else "",
"date": format_date(self.date, "medium"),
"member_name": self.member_name,
"start_time": format_time(self.start_time, "short"),
"evaluator": frappe.db.get_value("User", self.evaluator, "full_name"),
"evaluator": self.evaluator_name,
}
frappe.sendmail(

View File

@@ -48,9 +48,6 @@ class LMSCourse(Document):
if not self.upcoming and self.has_value_changed("upcoming"):
self.send_email_to_interested_users()
def after_insert(self):
capture("course_created", "lms")
def send_email_to_interested_users(self):
interested_users = frappe.get_all(
"LMS Course Interest", {"course": self.name}, ["name", "user"]

View File

@@ -10,6 +10,7 @@ from lms.lms.utils import has_course_instructor_role, has_course_moderator_role
class LMSQuestion(Document):
def validate(self):
validate_correct_answers(self)
update_question_title(self)
def validate_correct_answers(question):
@@ -62,6 +63,16 @@ def validate_possible_answer(question):
)
def update_question_title(question):
if not question.is_new():
question_rows = frappe.get_all(
"LMS Quiz Question", {"question": question.name}, pluck="name"
)
for row in question_rows:
frappe.db.set_value("LMS Quiz Question", row, "question_detail", question.question)
def get_correct_options(question):
correct_options = []
correct_option_fields = [

View File

@@ -9,16 +9,15 @@
"field_order": [
"title",
"max_attempts",
"limit_questions_to",
"show_answers",
"column_break_gaac",
"total_marks",
"passing_percentage",
"section_break_hsiv",
"show_answers",
"column_break_rocd",
"show_submission_history",
"column_break_dsup",
"section_break_tzbu",
"shuffle_questions",
"column_break_clsh",
"limit_questions_to",
"section_break_sbjx",
"questions",
"section_break_3",
@@ -91,11 +90,6 @@
"fieldtype": "Check",
"label": "Show Submission History"
},
{
"fieldname": "section_break_hsiv",
"fieldtype": "Section Break",
"label": "Settings"
},
{
"fieldname": "passing_percentage",
"fieldtype": "Int",
@@ -105,10 +99,6 @@
"non_negative": 1,
"reqd": 1
},
{
"fieldname": "column_break_rocd",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "total_marks",
@@ -119,10 +109,6 @@
"read_only": 1,
"reqd": 1
},
{
"fieldname": "column_break_dsup",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "shuffle_questions",
@@ -130,14 +116,23 @@
"label": "Shuffle Questions"
},
{
"depends_on": "shuffle_questions",
"fieldname": "limit_questions_to",
"fieldtype": "Int",
"label": "Limit Questions To"
},
{
"fieldname": "section_break_tzbu",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_clsh",
"fieldtype": "Column Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-07-19 18:21:26.681501",
"modified": "2024-08-09 12:21:36.256522",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Quiz",

View File

@@ -5,7 +5,7 @@ import json
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cstr, comma_and
from frappe.utils import cstr, comma_and, cint
from fuzzywuzzy import fuzz
from lms.lms.doctype.course_lesson.course_lesson import save_progress
from lms.lms.utils import (
@@ -30,12 +30,12 @@ class LMSQuiz(Document):
)
def validate_limit(self):
if self.limit_questions_to and self.limit_questions_to >= len(self.questions):
if self.limit_questions_to and cint(self.limit_questions_to) >= len(self.questions):
frappe.throw(
_("Limit cannot be greater than or equal to the number of questions in the quiz.")
)
if self.limit_questions_to and self.limit_questions_to < len(self.questions):
if self.limit_questions_to and cint(self.limit_questions_to) < len(self.questions):
marks = [question.marks for question in self.questions]
if len(set(marks)) > 1:
frappe.throw(_("All questions should have the same marks if the limit is set."))
@@ -43,10 +43,10 @@ class LMSQuiz(Document):
def calculate_total_marks(self):
if self.limit_questions_to:
self.total_marks = sum(
question.marks for question in self.questions[: self.limit_questions_to]
question.marks for question in self.questions[: cint(self.limit_questions_to)]
)
else:
self.total_marks = sum(question.marks for question in self.questions)
self.total_marks = sum(cint(question.marks) for question in self.questions)
def autoname(self):
if not self.name:
@@ -90,21 +90,19 @@ def quiz_summary(quiz, results):
question_details = frappe.db.get_value(
"LMS Quiz Question",
{"parent": quiz, "idx": result["question_index"]},
["question", "marks"],
{"parent": quiz, "question": result["question_name"]},
["question", "marks", "question_detail"],
as_dict=1,
)
result["question_name"] = question_details.question
result["question"] = frappe.db.get_value(
"LMS Question", question_details.question, "question"
)
result["question"] = question_details.question_detail
marks = question_details.marks if correct else 0
result["marks"] = marks
score += marks
del result["question_index"]
del result["question_name"]
quiz_details = frappe.db.get_value(
"LMS Quiz", quiz, ["total_marks", "passing_percentage", "lesson", "course"], as_dict=1
@@ -297,15 +295,6 @@ def check_choice_answers(question, answers):
question_details = frappe.db.get_value("LMS Question", question, fields, as_dict=1)
""" if question_details.multiple:
correct_answers = [ question_details[f"option_{num}"] for num in range(1,5) if question_details[f"is_correct_{num}"]]
print(answers)
for ans in correct_answers:
if ans not in answers:
is_correct.append(0)
else:
is_correct.append(1)
else: """
for num in range(1, 5):
if question_details[f"option_{num}"] in answers:
is_correct.append(question_details[f"is_correct_{num}"])

View File

@@ -10,14 +10,9 @@
"column_break_zdel",
"unsplash_access_key",
"livecode_url",
"course_settings_section",
"search_placeholder",
"column_break_iqxy",
"portal_course_creation",
"section_break_szgq",
"send_calendar_invite_for_evaluations",
"show_day_view",
"allow_student_progress",
"column_break_2",
"show_dashboard",
"show_courses",
@@ -48,7 +43,6 @@
"notifications",
"section_break_qlss",
"sidebar_items",
"mentor_request_tab",
"mentor_request_section",
"mentor_request_creation",
"mentor_request_status_update",
@@ -98,11 +92,6 @@
"fieldtype": "Column Break",
"label": "Show Tab in Batch"
},
{
"fieldname": "search_placeholder",
"fieldtype": "Data",
"label": "Course List Search Bar Placeholder"
},
{
"default": "0",
"fieldname": "terms_of_use",
@@ -139,13 +128,6 @@
"fieldname": "column_break_12",
"fieldtype": "Column Break"
},
{
"default": "Course Creator Role",
"fieldname": "portal_course_creation",
"fieldtype": "Select",
"label": "Course Creation Access Through Website To",
"options": "Course Creator Role\nAnyone"
},
{
"fieldname": "column_break_9",
"fieldtype": "Column Break"
@@ -203,19 +185,6 @@
"fieldtype": "Tab Break",
"label": "Signup Settings"
},
{
"fieldname": "mentor_request_tab",
"fieldtype": "Tab Break",
"hidden": 1,
"label": "Mentor Request"
},
{
"default": "0",
"fieldname": "allow_student_progress",
"fieldtype": "Check",
"hidden": 1,
"label": "Allow students to see each others progress in class"
},
{
"fieldname": "payment_section",
"fieldtype": "Section Break"
@@ -230,15 +199,6 @@
"fieldname": "column_break_cfcv",
"fieldtype": "Column Break"
},
{
"fieldname": "course_settings_section",
"fieldtype": "Section Break",
"label": "Course Settings"
},
{
"fieldname": "column_break_iqxy",
"fieldtype": "Column Break"
},
{
"fieldname": "razorpay_key",
"fieldtype": "Data",
@@ -423,7 +383,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2024-06-27 21:57:02.193336",
"modified": "2024-08-13 19:02:58.714080",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Settings",

View File

@@ -1,9 +0,0 @@
{% set title = frappe.db.get_value("LMS Course", doc.course, "title") %}
{% set timezone = frappe.db.get_value("LMS Batch", doc.batch, "timezone") %}
{% set timezone = timezone if timezone else '' %}
{% set evaluator_name = frappe.db.get_value("User", doc.evaluator, "full_name") %}
<p> {{ _("Hey {0}").format(doc.member_name) }} </p>
<p> {{ _('Your evaluation for the course {0} has been scheduled on {1} at {2} {3}.').format(title, frappe.utils.format_date(doc.date, "medium"), frappe.utils.format_time(doc.start_time, "short"), timezone) }}</p>
<p> {{ _("Your evaluator is {0}").format(evaluator_name) }} </p>
<p> {{ _("Please prepare well and be on time for the evaluations.") }} </p>

View File

@@ -1,34 +0,0 @@
{
"attach_print": 0,
"channel": "Email",
"creation": "2022-06-03 11:02:34.579145",
"days_in_advance": 0,
"docstatus": 0,
"doctype": "Notification",
"document_type": "LMS Certificate Request",
"enabled": 0,
"event": "New",
"idx": 0,
"is_standard": 1,
"message": "{% set title = frappe.db.get_value(\"LMS Course\", doc.course, \"title\") %}\n{% set timezone = frappe.db.get_value(\"LMS Batch\", doc.batch, \"timezone\") %}\n{% set timezone = timezone if timezone else '' %}\n{% set evaluator_name = frappe.db.get_value(\"User\", doc.evaluator, \"full_name\") %}\n\n<p> {{ _(\"Hey {0}\").format(doc.member_name) }} </p>\n<p> {{ _('Your evaluation for the course {0} has been scheduled on {1} at {2} {3}.').format(title, frappe.utils.format_date(doc.date, \"medium\"), frappe.utils.format_time(doc.start_time, \"short\"), timezone) }}</p>\n<p> {{ _(\"Your evaluator is {0}\").format(evaluator_name) }} </p>\n<p> {{ _(\"Please prepare well and be on time for the evaluations.\") }} </p>\n",
"message_type": "HTML",
"modified": "2024-08-01 12:17:40.647724",
"modified_by": "jannat@frappe.io",
"module": "LMS",
"name": "Certificate Request Creation",
"owner": "Administrator",
"recipients": [
{
"receiver_by_document_field": "member"
},
{
"receiver_by_document_field": "evaluator"
},
{
"receiver_by_role": "Frappe School Admin"
}
],
"send_system_notification": 0,
"send_to_all_assignees": 0,
"subject": "Your evaluation slot has been booked"
}

View File

@@ -1,6 +0,0 @@
import frappe
def get_context(context):
# do your magic here
pass

View File

@@ -1,8 +1,7 @@
{% set title = frappe.db.get_value("LMS Course", doc.course, "title") %}
{% set timezone = frappe.db.get_value("LMS Batch", doc.batch, "timezone") %}
{% set timezone = timezone if timezone else '' %}
{% set evaluator_name = frappe.db.get_value("User", doc.evaluator, "full_name") %}
<p> {{ _('Your evaluation for the course {0} has been scheduled on {1} at {2} {3}.').format(title, frappe.utils.format_date(doc.date, "medium"), frappe.utils.format_time(doc.start_time, "short"), timezone) }}</p>
<p> {{ _("Your evaluator is {0}").format(evaluator_name) }}
<p> {{ _("Hey {0}").format(doc.member_name) }} </p>
<br>
<p> {{ _('Your evaluation for the course {0} has been scheduled on {1} at {2} {3}.').format(doc.course_title, frappe.utils.format_date(doc.date, "medium"), frappe.utils.format_time(doc.start_time, "short"), doc.timezone) }}</p>
<br>
<p> {{ _("{0} is your evaluator").format(doc.evaluator_name) }} </p>
<br>
<p> {{ _("Please prepare well and be on time for the evaluations.") }} </p>

View File

@@ -11,10 +11,10 @@
"event": "Days Before",
"idx": 0,
"is_standard": 1,
"message": "{% set title = frappe.db.get_value(\"LMS Course\", doc.course, \"title\") %}\n{% set timezone = frappe.db.get_value(\"LMS Batch\", doc.batch, \"timezone\") %}\n{% set timezone = timezone if timezone else '' %}\n{% set evaluator_name = frappe.db.get_value(\"User\", doc.evaluator, \"full_name\") %}\n\n<p> {{ _('Your evaluation for the course {0} has been scheduled on {1} at {2} {3}.').format(title, frappe.utils.format_date(doc.date, \"medium\"), frappe.utils.format_time(doc.start_time, \"short\"), timezone) }}</p>\n<p> {{ _(\"Your evaluator is {0}\").format(evaluator_name) }}\n<p> {{ _(\"Please prepare well and be on time for the evaluations.\") }} </p>\n",
"message": "<p> {{ _(\"Hey {0}\").format(doc.member_name) }} </p>\n<br>\n<p> {{ _('Your evaluation for the course {0} has been scheduled on {1} at {2} {3}.').format(doc.course_title, frappe.utils.format_date(doc.date, \"medium\"), frappe.utils.format_time(doc.start_time, \"short\"), doc.timezone) }}</p>\n<br>\n<p> {{ _(\"{0} is your evaluator\").format(doc.evaluator_name) }} </p>\n<br>\n<p> {{ _(\"Please prepare well and be on time for the evaluations.\") }} </p>\n",
"message_type": "HTML",
"modified": "2024-07-10 15:51:33.803704",
"modified_by": "sayali@erpnext.com",
"modified": "2024-09-05 16:33:42.212842",
"modified_by": "Administrator",
"module": "LMS",
"name": "Certificate Request Reminder",
"owner": "Administrator",

18
lms/lms/telemetry.py Normal file
View File

@@ -0,0 +1,18 @@
import frappe
@frappe.whitelist()
def is_enabled():
return bool(
frappe.get_system_settings("enable_telemetry")
and frappe.conf.get("posthog_host")
and frappe.conf.get("posthog_project_id")
)
@frappe.whitelist()
def get_credentials():
return {
"project_id": frappe.conf.get("posthog_project_id"),
"telemetry_host": frappe.conf.get("posthog_host"),
}

View File

@@ -517,13 +517,6 @@ def can_create_courses(course, member=None):
if has_course_instructor_role(member) and member in instructors:
return True
portal_course_creation = frappe.db.get_single_value(
"LMS Settings", "portal_course_creation"
)
if portal_course_creation == "Anyone" and member in instructors:
return True
if not course and has_course_instructor_role(member):
return True

View File

@@ -9,7 +9,7 @@
"label": "Enrollments"
}
],
"content": "[{\"id\":\"jNO4sdKxHu\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Get Started</b></span>\",\"col\":12}},{\"id\":\"5s0qRBc4rY\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/lms/courses\\\">Visit LMS Portal</a>\",\"col\":4}},{\"id\":\"lGMuNLpmv-\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/lms/courses/new/edit\\\">Create a Course</a>\",\"col\":4}},{\"id\":\"3TVyc9AkPy\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/app/web-page/new-web-page-1\\\">Setup a Home Page</a>\",\"col\":4}},{\"id\":\"9zcbqpu2gm\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/app/lms-settings/LMS%20Settings\\\">LMS Setting</a>\",\"col\":4}},{\"id\":\"0ATmnKmXjc\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"https://docs.frappe.io/learning\\\">Documentation</a>\",\"col\":4}},{\"id\":\"C128a4abjX\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"5q4sPiv2ci\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Signups\",\"col\":6}},{\"id\":\"8NSaRaEV5u\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Enrollments\",\"col\":6}},{\"id\":\"kMuzko0uAU\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"iuvIOHmztI\",\"type\":\"header\",\"data\":{\"text\":\"<span style=\\\"font-size: 18px;\\\"><b>Statistics</b></span>\",\"col\":12}},{\"id\":\"l0VTd66Uy2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Users\",\"col\":4}},{\"id\":\"wAWZin1KKk\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Course\",\"col\":4}},{\"id\":\"RLrIlFx0Hd\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Enrollments\",\"col\":4}},{\"id\":\"OuhWkhCQmq\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Course Completed\",\"col\":4}},{\"id\":\"3g8QmNqUXG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Certificate\",\"col\":4}},{\"id\":\"EZsdsujs8N\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Evaluation\",\"col\":4}},{\"id\":\"s-nfsFQbGV\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"jeOBWBzHEa\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Master</b></span>\",\"col\":12}},{\"id\":\"sVhgfS5GIh\",\"type\":\"card\",\"data\":{\"card_name\":\"Course Data\",\"col\":4}},{\"id\":\"Iea0snm4Fg\",\"type\":\"card\",\"data\":{\"card_name\":\"Course Stats\",\"col\":4}},{\"id\":\"bZB7RqOl6a\",\"type\":\"card\",\"data\":{\"card_name\":\"Certification\",\"col\":4}}]",
"content": "[{\"id\":\"jNO4sdKxHu\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Get Started</b></span>\",\"col\":12}},{\"id\":\"5s0qRBc4rY\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/lms/courses\\\">Visit LMS Portal</a>\",\"col\":4}},{\"id\":\"lGMuNLpmv-\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/lms/courses/new/edit\\\">Create a Course</a>\",\"col\":4}},{\"id\":\"3TVyc9AkPy\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/app/web-page/new-web-page-1\\\">Setup a Home Page</a>\",\"col\":4}},{\"id\":\"9zcbqpu2gm\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/app/lms-settings/LMS%20Settings\\\">LMS Settings</a>\",\"col\":4}},{\"id\":\"0ATmnKmXjc\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"https://docs.frappe.io/learning\\\">Documentation</a>\",\"col\":4}},{\"id\":\"C128a4abjX\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"5q4sPiv2ci\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Signups\",\"col\":6}},{\"id\":\"8NSaRaEV5u\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Enrollments\",\"col\":6}},{\"id\":\"kMuzko0uAU\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"iuvIOHmztI\",\"type\":\"header\",\"data\":{\"text\":\"<span style=\\\"font-size: 18px;\\\"><b>Statistics</b></span>\",\"col\":12}},{\"id\":\"l0VTd66Uy2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Users\",\"col\":4}},{\"id\":\"wAWZin1KKk\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Course\",\"col\":4}},{\"id\":\"RLrIlFx0Hd\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Enrollments\",\"col\":4}},{\"id\":\"OuhWkhCQmq\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Course Completed\",\"col\":4}},{\"id\":\"3g8QmNqUXG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Certificate\",\"col\":4}},{\"id\":\"EZsdsujs8N\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Evaluation\",\"col\":4}},{\"id\":\"s-nfsFQbGV\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"jeOBWBzHEa\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Master</b></span>\",\"col\":12}},{\"id\":\"sVhgfS5GIh\",\"type\":\"card\",\"data\":{\"card_name\":\"Course Data\",\"col\":4}},{\"id\":\"Iea0snm4Fg\",\"type\":\"card\",\"data\":{\"card_name\":\"Course Stats\",\"col\":4}},{\"id\":\"bZB7RqOl6a\",\"type\":\"card\",\"data\":{\"card_name\":\"Certification\",\"col\":4}}]",
"creation": "2021-10-21 17:20:01.358903",
"custom_blocks": [],
"docstatus": 0,
@@ -145,7 +145,7 @@
"type": "Link"
}
],
"modified": "2024-06-27 21:19:06.273056",
"modified": "2024-08-09 13:19:06.273056",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS",
@@ -213,4 +213,4 @@
}
],
"title": "LMS"
}
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More