Compare commits

...

146 Commits

Author SHA1 Message Date
Frappe PR Bot
4b3a71e424 chore(release): Bumped to Version 2.10.0 2024-11-06 05:17:44 +00:00
Jannat Patel
5499e7294d Merge pull request #1095 from pateljannat/issues-46
fix: misc issues
2024-11-06 10:34:17 +05:30
Jannat Patel
fe1f78f8aa Merge pull request #1093 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-11-05 16:13:22 +05:30
Jannat Patel
1709c6b658 Merge pull request #1094 from frappe/pot_develop_2024-11-01
chore: update POT file
2024-11-05 16:13:05 +05:30
Jannat Patel
d3583a2cfb fix: set event in live class 2024-11-04 12:01:58 +05:30
Jannat Patel
634035fbc0 fix: misc issues 2024-11-04 09:54:53 +05:30
Jannat Patel
3c5b18411b chore: Swedish translations 2024-11-03 20:08:50 +05:30
frappe-pr-bot
82bb45a9ef chore: update POT file 2024-11-01 16:04:24 +00:00
Jannat Patel
373f3df196 chore: Turkish translations 2024-11-01 19:00:24 +05:30
Jannat Patel
6021f15bac chore: Turkish translations 2024-10-31 18:59:42 +05:30
Jannat Patel
8f6f35d7c1 Merge pull request #1090 from iamejaaz/add-required-attribute
feat: add required indicator on the course add page
2024-10-31 11:48:06 +05:30
Jannat Patel
7aa5f4d20b Merge pull request #1086 from 0xflotus/patch-1
fix: small bug in course_progress_summary.py
2024-10-31 11:39:08 +05:30
Jannat Patel
64b54b05a6 Merge pull request #1085 from pateljannat/new-onboarding
feat: onboarding
2024-10-31 11:35:45 +05:30
Jannat Patel
22b1f22df4 fix: empty state conditions 2024-10-31 11:16:39 +05:30
Jannat Patel
ae4e5539d7 fix: removed chapter description when fetching outline 2024-10-31 09:51:50 +05:30
Ejaaz Khan
dbd96329b5 style: format code with precommit 2024-10-31 00:22:28 +05:30
Ejaaz Khan
c118ec7c4a feat: add required indicator on the course add page 2024-10-30 23:59:26 +05:30
0xflotus
7aab449502 fix: changed ranges 2024-10-30 18:47:36 +01:00
Jannat Patel
cf166b3a57 Merge pull request #1089 from 0xflotus/patch-2
chore: add some german translations
2024-10-30 23:12:33 +05:30
Jannat Patel
da5910d40d test: changed labels as per new onboarding 2024-10-30 23:11:47 +05:30
Jannat Patel
8640ecf9be refactor: course list data 2024-10-30 22:12:59 +05:30
0xflotus
c4faceff30 chore: add some german translations 2024-10-30 14:55:25 +01:00
0xflotus
01bd017bda fix: fixed labels 2024-10-29 19:33:22 +01:00
0xflotus
d76357981b fix: small bug in course_progress_summary.py
This is a small logical fix.

Otherwise if `row.progress == 10 or row.progress == 40 or row.progress == 70` wouldn't have an effect.
2024-10-29 19:28:14 +01:00
Jannat Patel
19b759e9fb feat: onboarding 2024-10-29 23:00:38 +05:30
Jannat Patel
df3bca6405 Merge pull request #1081 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-10-28 09:34:34 +05:30
Jannat Patel
5cde79b5eb chore: Persian translations 2024-10-27 17:10:17 +05:30
Jannat Patel
9b35cdbddc chore: Bosnian translations 2024-10-26 16:54:33 +05:30
Jannat Patel
70ec22004a chore: Persian translations 2024-10-26 16:54:32 +05:30
Jannat Patel
95ed77421a chore: Chinese Simplified translations 2024-10-26 16:54:31 +05:30
Jannat Patel
d64ec9817c chore: Turkish translations 2024-10-26 16:54:29 +05:30
Jannat Patel
ce01b7634f chore: Swedish translations 2024-10-26 16:54:28 +05:30
Jannat Patel
e0819f83bc chore: Russian translations 2024-10-26 16:54:27 +05:30
Jannat Patel
f87d28c2f5 chore: Polish translations 2024-10-26 16:54:25 +05:30
Jannat Patel
544b59744b chore: Hungarian translations 2024-10-26 16:54:24 +05:30
Jannat Patel
467dfb831d chore: German translations 2024-10-26 16:54:23 +05:30
Jannat Patel
4c4b4eaf55 chore: Arabic translations 2024-10-26 16:54:21 +05:30
Jannat Patel
227e5d00e5 chore: Spanish translations 2024-10-26 16:54:20 +05:30
Jannat Patel
73e9e384c8 chore: French translations 2024-10-26 16:54:18 +05:30
Jannat Patel
5bebdcba68 Merge pull request #1080 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-10-25 16:48:48 +05:30
Jannat Patel
1c2e52ae4b chore: Esperanto translations 2024-10-25 16:35:56 +05:30
Jannat Patel
9377e89561 chore: Bosnian translations 2024-10-25 16:35:54 +05:30
Jannat Patel
4cae05ecbe chore: Persian translations 2024-10-25 16:35:53 +05:30
Jannat Patel
909dcfd51e chore: Chinese Simplified translations 2024-10-25 16:35:51 +05:30
Jannat Patel
2bd96a1f2a chore: Turkish translations 2024-10-25 16:35:49 +05:30
Jannat Patel
aca41080ee chore: Swedish translations 2024-10-25 16:35:48 +05:30
Jannat Patel
1c351696a9 chore: Russian translations 2024-10-25 16:35:46 +05:30
Jannat Patel
51a8958aa6 chore: Polish translations 2024-10-25 16:35:45 +05:30
Jannat Patel
777b8aed02 chore: Hungarian translations 2024-10-25 16:35:43 +05:30
Jannat Patel
3672b90075 chore: German translations 2024-10-25 16:35:42 +05:30
Jannat Patel
92c7e613db chore: Arabic translations 2024-10-25 16:35:40 +05:30
Jannat Patel
5c58b85a00 chore: Spanish translations 2024-10-25 16:35:39 +05:30
Jannat Patel
8af82daa37 chore: French translations 2024-10-25 16:35:36 +05:30
Jannat Patel
224bb18d3e Merge pull request #1077 from pateljannat/issues-45
fix: show live class start button only to moderators and evaluators
2024-10-23 12:53:42 +05:30
Jannat Patel
aab7bdcc20 fix: show live class start button only to moderators and evaluators 2024-10-23 11:02:16 +05:30
Jannat Patel
c5ca428d98 Merge pull request #1076 from pateljannat/issues-44
fix: misc issues
2024-10-23 10:55:42 +05:30
Frappe PR Bot
af0cc7126b chore(release): Bumped to Version 2.9.0 2024-10-23 05:09:14 +00:00
Jannat Patel
a085050d27 build: removed frappe-ui package 2024-10-23 10:36:26 +05:30
Jannat Patel
2442f35f56 fix: added is_instructor to jinja 2024-10-23 10:35:26 +05:30
Jannat Patel
ed79ea536b Merge pull request #1072 from frappe/pot_develop_2024-10-18
chore: update POT file
2024-10-18 23:06:49 +05:30
frappe-pr-bot
b3d0aecd14 chore: update POT file 2024-10-18 16:04:26 +00:00
Jannat Patel
5f43e67c0b Merge pull request #1068 from pateljannat/payment-issues
fix: batch enrollment after payment completion
2024-10-17 10:39:46 +05:30
Jannat Patel
49a765a9a6 style: fix spacing 2024-10-17 10:31:56 +05:30
Jannat Patel
4d82bc86e8 style: fix spacing 2024-10-17 10:30:06 +05:30
Jannat Patel
8fe02b83b8 fix: batch enrollment after payment completion 2024-10-17 09:27:24 +05:30
Jannat Patel
9c9075606b Merge pull request #1059 from frappe/pot_develop_2024-10-11
chore: update POT file
2024-10-15 19:38:24 +05:30
Jannat Patel
53285a0d19 fix: misc issues 2024-10-14 19:17:32 +05:30
Jannat Patel
9cdeaebb47 Merge pull request #1062 from pateljannat/quiz-timer
feat: timer in quiz
2024-10-14 16:11:55 +05:30
Jannat Patel
a9cb52c68b fix: hide timer instructions if duration is not set 2024-10-14 15:49:27 +05:30
Jannat Patel
f33e950e83 feat: timer in quiz 2024-10-14 14:31:26 +05:30
Jannat Patel
9c9b5963fe Merge pull request #1060 from pateljannat/issues-43
fix: redirect to login before enrollment
2024-10-11 22:33:52 +05:30
Jannat Patel
1597054cc9 fix: redirect to login before enrollment 2024-10-11 22:18:18 +05:30
frappe-pr-bot
deba6aa845 chore: update POT file 2024-10-11 16:04:13 +00:00
Jannat Patel
2d8ba3b84e Merge pull request #1058 from pateljannat/issues-42
fix: batch self enrollment
2024-10-11 19:22:50 +05:30
Jannat Patel
e56b28abad chore: removed unnecessary lines 2024-10-11 19:17:56 +05:30
Jannat Patel
eb350c5a20 fix: batch self enrollment 2024-10-11 19:16:40 +05:30
Jannat Patel
961d5ec77b Merge pull request #1057 from pateljannat/settings-minor-changes
fix: misc ux issues
2024-10-11 16:18:19 +05:30
Jannat Patel
fa566514aa fix: image fetch for settings 2024-10-11 15:32:41 +05:30
Jannat Patel
6e97449bf7 fix: misc ux issues 2024-10-11 13:39:30 +05:30
Jannat Patel
016dafb3c3 Merge pull request #1056 from pateljannat/issues-41
fix: misc issues
2024-10-10 16:43:59 +05:30
Jannat Patel
675bcc8956 test: replaced FrappeTestCase with UnitTestCase 2024-10-10 16:20:53 +05:30
Jannat Patel
aba4c034fc fix: misc issues 2024-10-10 14:48:59 +05:30
Jannat Patel
c76d8c582f Merge pull request #1052 from pateljannat/issues-40
fix: misc quiz issues
2024-10-09 19:17:01 +05:30
Jannat Patel
f1cb0e6f3c fix: usd conversion 2024-10-09 19:07:25 +05:30
Jannat Patel
d296687456 fix: misc quiz issues 2024-10-09 16:03:56 +05:30
Jannat Patel
5b68001c94 Merge pull request #1049 from pateljannat/issues-39
fix: create order for razorpay
2024-10-09 11:59:57 +05:30
Frappe PR Bot
736d79b8c9 chore(release): Bumped to Version 2.8.0 2024-10-09 06:04:56 +00:00
Jannat Patel
98c0bd5f3e Merge pull request #1042 from frappe/pot_develop_2024-10-04
chore: update POT file
2024-10-09 11:34:01 +05:30
Jannat Patel
8b1d9bb5a9 fix: create order for razorpay 2024-10-09 11:31:31 +05:30
Jannat Patel
289a0f9122 Merge pull request #1046 from pateljannat/issues-38
fix: quiz columns
2024-10-08 16:27:04 +05:30
Jannat Patel
3cd08c80c8 fix: reduced with of marks column 2024-10-08 16:09:21 +05:30
Jannat Patel
3d82c36250 fix: quiz columns 2024-10-08 16:03:37 +05:30
Jannat Patel
9b9af0215a Merge pull request #1045 from pateljannat/issues-37
fix: using google docs viewer to render pdf
2024-10-08 12:37:19 +05:30
Jannat Patel
2e4cf02737 fix: using google docs viewer to render pdf 2024-10-08 12:00:51 +05:30
Jannat Patel
438e9e1c47 Merge pull request #1044 from pateljannat/open-ended-questions
feat: open ended questions
2024-10-08 10:37:44 +05:30
Jannat Patel
36ded70eef fix: only allow instructor and moderator on submission page 2024-10-08 10:21:45 +05:30
Jannat Patel
ba78a15a1f fix: ui test button label 2024-10-08 10:14:04 +05:30
Jannat Patel
93061194bb fix: error toast when saving marks 2024-10-08 10:13:07 +05:30
Jannat Patel
6d41e4e552 feat: open ended questions 2024-10-07 21:18:42 +05:30
frappe-pr-bot
3b06968d0a chore: update POT file 2024-10-04 16:04:32 +00:00
Frappe PR Bot
fc81f1aa26 chore(release): Bumped to Version 2.7.0 2024-10-02 06:53:07 +00:00
Jannat Patel
59d8848125 Merge pull request #1035 from pateljannat/payments
feat: payments app integration
2024-10-02 12:22:00 +05:30
Jannat Patel
a067695f71 fix: removed help article from course lesson 2024-10-01 15:43:45 +05:30
Jannat Patel
be870e8145 fix: payment gateway fields 2024-10-01 15:17:17 +05:30
Jannat Patel
8a17dca351 fix: minor ui changes 2024-10-01 10:43:37 +05:30
Jannat Patel
1c9f636ad1 Merge pull request #1032 from frappe/pot_develop_2024-09-27
chore: update POT file
2024-10-01 09:42:57 +05:30
Jannat Patel
008cc66cdd chore: refactor payment settings 2024-09-30 18:30:53 +05:30
Jannat Patel
b6bf9c0032 Merge branch 'develop' of https://github.com/frappe/lms into payments 2024-09-30 10:16:52 +05:30
Jannat Patel
d295898674 Merge pull request #1033 from pateljannat/issues-35
fix: misc UI fixes
2024-09-27 22:15:43 +05:30
frappe-pr-bot
4fdca4691a chore: update POT file 2024-09-27 16:04:07 +00:00
Jannat Patel
7c055af496 fix: telemetry capture issue 2024-09-27 21:32:46 +05:30
Jannat Patel
60a3da283e refactor: billing page ui 2024-09-27 14:14:03 +05:30
Jannat Patel
576258ec6e fix: pass options to setting fields 2024-09-27 07:05:16 +05:30
Jannat Patel
01120fbc48 chore: resolved conflicts 2024-09-27 06:24:12 +05:30
Jannat Patel
ad07f883b5 fix: misc UI fixes 2024-09-27 06:19:38 +05:30
Jannat Patel
bb9b179e05 Merge pull request #1031 from pateljannat/brand-settings
feat:  brand settings
2024-09-26 14:01:19 +05:30
Jannat Patel
11a9bff57d fix: dirty form for branding section 2024-09-26 12:58:00 +05:30
Jannat Patel
e18f0c9dad feat: brand settings 2024-09-26 12:09:58 +05:30
Jannat Patel
41ad3d00de Merge pull request #1030 from pateljannat/fix-evaluation-issue
fix: evaluation error message issue
2024-09-25 11:29:42 +05:30
Frappe PR Bot
b74c1670ca chore(release): Bumped to Version 2.6.0 2024-09-25 05:47:45 +00:00
Jannat Patel
33c76e842f fix: evaluation error message issue 2024-09-25 11:10:26 +05:30
Jannat Patel
35a7cce283 feat: payment gateway settings 2024-09-25 10:50:53 +05:30
Jannat Patel
e0f569c382 feat: payment flow with payments app 2024-09-24 18:14:34 +05:30
Jannat Patel
d8ab88be28 Merge branch 'develop' of https://github.com/frappe/lms into payments 2024-09-24 14:14:49 +05:30
Jannat Patel
04552bdef6 Merge pull request #1025 from pateljannat/categories-in-courses
feat: course categories
2024-09-24 12:55:41 +05:30
Jannat Patel
ad5bf89b35 test: enter instructor value 2024-09-24 12:32:07 +05:30
Jannat Patel
88b38dfd83 test: category test in course form in UI 2024-09-24 12:22:34 +05:30
Jannat Patel
75e9ca395f chore: removed unnecessary custom fields 2024-09-24 10:59:27 +05:30
Jannat Patel
6fb206cc4e feat: updating category from settings 2024-09-24 10:33:23 +05:30
Jannat Patel
62cb198492 Merge branch 'develop' of https://github.com/frappe/lms into categories-in-courses 2024-09-23 19:23:49 +05:30
Jannat Patel
9609329f01 Merge pull request #1028 from pateljannat/fix-signup-customisations
fix: signup conditions
2024-09-23 19:12:29 +05:30
Jannat Patel
c93808af94 fix: signup conditions 2024-09-23 18:41:35 +05:30
Jannat Patel
58866260ec Merge branch 'develop' of https://github.com/frappe/lms into categories-in-courses 2024-09-23 18:39:19 +05:30
Jannat Patel
e6157ff411 Merge pull request #1027 from pateljannat/refactor-signup-customisations
refactor: signup customisations
2024-09-23 18:23:04 +05:30
Jannat Patel
8cca8920ee chore: removed unnecessary file 2024-09-23 18:07:08 +05:30
Jannat Patel
ab039dbd46 refactor: signup customisations 2024-09-23 18:04:36 +05:30
Jannat Patel
9853ab3fd9 Merge pull request #1024 from frappe/pot_develop_2024-09-20
chore: update POT file
2024-09-23 16:11:57 +05:30
Jannat Patel
dc2bf9f13e feat: category settings 2024-09-23 16:11:17 +05:30
Jannat Patel
7c90ca4040 feat: category in settings 2024-09-20 22:15:59 +05:30
frappe-pr-bot
75a90e1f39 chore: update POT file 2024-09-20 16:04:14 +00:00
Jannat Patel
bc4b17cc3d Merge pull request #1022 from pateljannat/evaluator_name_issue
fix: evaluator name issue
2024-09-19 15:18:05 +05:30
Jannat Patel
8c454a333e fix: evaluator name issue 2024-09-19 14:19:55 +05:30
Jannat Patel
cef4b70182 feat: payment through payments app 2024-09-19 12:46:56 +05:30
Jannat Patel
3cda563583 fix: padding of settings modal 2024-09-18 14:46:29 +05:30
Jannat Patel
545326a02a Merge pull request #1021 from pateljannat/fix-help-video-url
fix: url for lesson help videos
2024-09-18 14:42:33 +05:30
Jannat Patel
14ce5d7e23 fix: url for lesson help videos 2024-09-18 11:49:38 +05:30
136 changed files with 77837 additions and 9210 deletions

View File

@@ -13,6 +13,6 @@ module.exports = defineConfig({
openMode: 0,
},
e2e: {
baseUrl: "http://test_site_ui:8000",
baseUrl: "http://test:8000",
},
});

View File

@@ -5,7 +5,7 @@ describe("Course Creation", () => {
cy.visit("/lms/courses");
// Create a course
cy.get("a").contains("New Course").click();
cy.get("header").children().last().children().last().click();
cy.wait(1000);
cy.url().should("include", "/courses/new/edit");
@@ -31,12 +31,35 @@ describe("Course Creation", () => {
.contains("Preview Video")
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
cy.get(".search-input").click().type("frappe");
cy.wait(1000);
cy.get("label")
.contains("Category")
.parent()
.within(() => {
cy.get("button").click();
});
cy.get("[id^=headlessui-combobox-option-")
.should("be.visible")
.first()
.click();
/* Instructor */
cy.get("label")
.contains("Instructors")
.parent()
.within(() => {
cy.get("input").click().type("frappe");
cy.get("input")
.invoke("attr", "aria-controls")
.as("instructor_list_id");
});
cy.get("@instructor_list_id").then((instructor_list_id) => {
cy.get(`[id^=${instructor_list_id}`)
.should("be.visible")
.within(() => {
cy.get("[id^=headlessui-combobox-option-").first().click();
});
});
cy.get("label").contains("Published").click();
cy.get("label").contains("Published On").type("2021-01-01");
cy.button("Save").click();
@@ -50,7 +73,7 @@ describe("Course Creation", () => {
.should("be.visible")
.within(() => {
cy.get("label").contains("Title").type("Test Chapter");
cy.button("Add Chapter").click();
cy.button("Create").click();
});
// Add Lesson

View File

@@ -18,10 +18,12 @@
"@editorjs/nested-list": "^1.4.2",
"@editorjs/paragraph": "^2.11.3",
"@editorjs/simple-image": "^1.6.0",
"ace-builds": "^1.36.2",
"chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.69",
"frappe-ui": "^0.1.72",
"lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0",
"pinia": "^2.0.33",

View File

@@ -14,8 +14,10 @@ import DesktopLayout from './components/DesktopLayout.vue'
import MobileLayout from './components/MobileLayout.vue'
import { stopSession } from '@/telemetry'
import { init as initTelemetry } from '@/telemetry'
import { usersStore } from '@/stores/user'
const screenSize = useScreenSize()
let { userResource } = usersStore()
const Layout = computed(() => {
if (screenSize.width < 640) {
@@ -26,6 +28,7 @@ const Layout = computed(() => {
})
onMounted(async () => {
if (!userResource.data) return
await initTelemetry()
})

View File

@@ -25,7 +25,7 @@
</div>
</template>
<script setup>
import { createListResource, Avatar } from 'frappe-ui'
import { createResource, Avatar } from 'frappe-ui'
import { timeAgo } from '@/utils'
const props = defineProps({
@@ -35,24 +35,15 @@ const props = defineProps({
},
})
const communications = createListResource({
doctype: 'Communication',
fields: [
'subject',
'content',
'recipients',
'cc',
'communication_date',
'sender',
'sender_full_name',
],
filters: {
reference_doctype: 'LMS Batch',
reference_name: props.batch,
const communications = createResource({
url: 'lms.lms.api.get_announcements',
makeParams(value) {
return {
batch: props.batch,
}
},
orderBy: 'communication_date desc',
auto: true,
cache: ['batch', props.batch],
cache: ['announcement', props.batch],
})
</script>
<style>

View File

@@ -107,6 +107,7 @@ const unreadCount = ref(0)
const sidebarLinks = ref(getSidebarLinks())
const showPageModal = ref(false)
const isModerator = ref(false)
const isInstructor = ref(false)
const pageToEdit = ref(null)
const showWebPages = ref(false)
@@ -167,6 +168,17 @@ const addNotifications = () => {
}
}
const addQuizzes = () => {
if (isInstructor.value || isModerator.value) {
sidebarLinks.value.push({
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
activeFor: ['Quizzes', 'QuizForm'],
})
}
}
const openPageModal = (link) => {
showPageModal.value = true
pageToEdit.value = link
@@ -197,6 +209,8 @@ const getSidebarFromStorage = () => {
watch(userResource, () => {
if (userResource.data) {
isModerator.value = userResource.data.is_moderator
isInstructor.value = userResource.data.is_instructor
addQuizzes()
}
})

View File

@@ -160,7 +160,7 @@ const getRowRoute = (row) => {
}
} else {
return {
name: 'Quiz',
name: 'QuizPage',
params: {
quizID: row.assessment_name,
},

View File

@@ -56,7 +56,6 @@ const props = defineProps({
onMounted(() => {
setTimeout(() => {
audio.value = document.querySelector('audio')
console.log(audio.value)
audio.value.onloadedmetadata = () => {
duration.value = audio.value.duration
}

View File

@@ -75,6 +75,7 @@
variant="solid"
class="w-full mt-2"
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
@click="enrollInBatch()"
>
{{ __('Enroll Now') }}
</Button>
@@ -97,11 +98,13 @@
</template>
<script setup>
import { inject, computed } from 'vue'
import { Badge, Button } from 'frappe-ui'
import { Badge, Button, createResource } from 'frappe-ui'
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime } from '@/utils'
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils'
import DateRange from '@/components/Common/DateRange.vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const user = inject('$user')
const props = defineProps({
@@ -111,6 +114,39 @@ const props = defineProps({
},
})
const enroll = createResource({
url: 'lms.lms.utils.enroll_in_batch',
makeParams(values) {
return {
batch: props.batch.data.name,
}
},
})
const enrollInBatch = () => {
if (!user.data) {
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
}
enroll.submit(
{},
{
onSuccess(data) {
showToast(
__('Success'),
__('You have been enrolled in this batch'),
'check'
)
router.push({
name: 'Batch',
params: {
batchName: props.batch.data.name,
},
})
},
}
)
}
const seats_left = computed(() => {
if (props.batch.data?.seat_count) {
return props.batch.data?.seat_count - props.batch.data?.students?.length

View File

@@ -0,0 +1,92 @@
<template>
<div class="flex flex-col justify-between min-h-0">
<div>
<div class="flex items-center justify-between">
<div class="font-semibold mb-1">
{{ __(label) }}
</div>
<Badge
v-if="isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</div>
<div class="text-xs text-gray-600">
{{ __(description) }}
</div>
</div>
<div class="overflow-y-auto">
<SettingFields :fields="fields" :data="data.data" />
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="saveSettings.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
</div>
</div>
</template>
<script setup>
import { createResource, Button, Badge } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue'
import { watch, ref } from 'vue'
const isDirty = ref(false)
const props = defineProps({
fields: {
type: Array,
required: true,
},
data: {
type: Object,
required: true,
},
label: {
type: String,
required: true,
},
description: {
type: String,
},
})
const saveSettings = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Website Settings',
name: 'Website Settings',
fieldname: values.fields,
}
},
})
const update = () => {
let fieldsToSave = {}
let imageFields = ['favicon', 'banner_image', 'footer_logo']
props.fields.forEach((f) => {
if (imageFields.includes(f.name)) {
fieldsToSave[f.name] = f.value ? f.value.file_url : null
} else {
fieldsToSave[f.name] = f.value
}
})
saveSettings.submit(
{
fields: fieldsToSave,
},
{
onSuccess(data) {
isDirty.value = false
},
}
)
}
watch(props.data, (newData) => {
if (newData && !isDirty.value) {
isDirty.value = true
}
})
</script>

View File

@@ -0,0 +1,151 @@
<template>
<div class="flex flex-col min-h-0">
<div class="flex items-center justify-between">
<div class="text-xl font-semibold mb-1">
{{ label }}
</div>
<Button @click="() => showCategoryForm()">
<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
v-if="showForm"
class="flex items-center justify-between my-4 space-x-2"
>
<FormControl
ref="categoryInput"
v-model="category"
:placeholder="__('Category Name')"
class="flex-1"
/>
<Button @click="addCategory()" variant="subtle">
{{ __('Add') }}
</Button>
</div>
<div class="overflow-y-scroll">
<div class="text-base divide-y">
<FormControl
:value="cat.category"
type="text"
v-for="cat in categories.data"
class="form-control"
@change.stop="(e) => update(cat.name, e.target.value)"
/>
</div>
</div>
</div>
</template>
<script setup>
import {
Button,
FormControl,
createListResource,
createResource,
debounce,
} from 'frappe-ui'
import { Plus, X } from 'lucide-vue-next'
import { ref } from 'vue'
const showForm = ref(false)
const category = ref(null)
const categoryInput = ref(null)
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
})
const categories = createListResource({
doctype: 'LMS Category',
fields: ['name', 'category'],
auto: true,
})
const newCategory = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Category',
category: category.value,
},
}
},
})
const addCategory = () => {
newCategory.submit(
{},
{
onSuccess(data) {
categories.reload()
category.value = null
},
}
)
}
const showCategoryForm = () => {
showForm.value = !showForm.value
setTimeout(() => {
categoryInput.value.$el.querySelector('input').focus()
}, 0)
}
const updateCategory = createResource({
url: 'frappe.client.rename_doc',
makeParams(values) {
return {
doctype: 'LMS Category',
old_name: values.name,
new_name: values.category,
}
},
})
const update = (name, value) => {
updateCategory.submit(
{
name: name,
category: value,
},
{
onSuccess() {
categories.reload()
},
}
)
}
</script>
<style>
.form-control input {
padding: 1.25rem 0;
border-color: transparent;
background: white;
}
.form-control input:focus {
outline: transparent;
background: white;
box-shadow: none;
border-color: transparent;
}
.form-control input:hover {
outline: transparent;
background: white;
box-shadow: none;
border-color: transparent;
}
</style>

View File

@@ -0,0 +1,204 @@
<template>
<div
class="editor flex flex-col gap-1"
:style="{
height: height,
}"
>
<span class="text-xs" v-if="label">
{{ label }}
</span>
<div
ref="editor"
class="h-auto flex-1 overflow-hidden overscroll-none !rounded border border-outline-gray-2 bg-surface-gray-2 dark:bg-gray-900"
/>
<span
class="mt-1 text-xs text-gray-600"
v-show="description"
v-html="description"
></span>
<Button
v-if="showSaveButton"
@click="emit('save', aceEditor?.getValue())"
class="mt-3"
>
{{ __('Save') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { useDark } from '@vueuse/core'
import ace from 'ace-builds'
import 'ace-builds/src-min-noconflict/ext-searchbox'
import 'ace-builds/src-min-noconflict/theme-chrome'
import 'ace-builds/src-min-noconflict/theme-twilight'
import { PropType, onMounted, ref, watch } from 'vue'
import { Button } from 'frappe-ui'
const isDark = useDark({
attribute: 'data-theme',
})
const props = defineProps({
modelValue: {
type: [Object, String, Array],
},
type: {
type: String as PropType<'JSON' | 'HTML' | 'Python' | 'JavaScript' | 'CSS'>,
default: 'JSON',
},
label: {
type: String,
default: '',
},
readonly: {
type: Boolean,
default: false,
},
height: {
type: String,
default: '250px',
},
showLineNumbers: {
type: Boolean,
default: false,
},
autofocus: {
type: Boolean,
default: true,
},
showSaveButton: {
type: Boolean,
default: false,
},
description: {
type: String,
default: '',
},
})
const emit = defineEmits(['save', 'update:modelValue'])
const editor = ref<HTMLElement | null>(null)
let aceEditor = null as ace.Ace.Editor | null
onMounted(() => {
setupEditor()
})
const setupEditor = () => {
aceEditor = ace.edit(editor.value as HTMLElement)
resetEditor(props.modelValue as string, true)
aceEditor.setReadOnly(props.readonly)
aceEditor.setOptions({
fontSize: '12px',
useWorker: false,
showGutter: props.showLineNumbers,
wrap: props.showLineNumbers,
})
if (props.type === 'CSS') {
import('ace-builds/src-noconflict/mode-css').then(() => {
aceEditor?.session.setMode('ace/mode/css')
})
} else if (props.type === 'JavaScript') {
import('ace-builds/src-noconflict/mode-javascript').then(() => {
aceEditor?.session.setMode('ace/mode/javascript')
})
} else if (props.type === 'Python') {
import('ace-builds/src-noconflict/mode-python').then(() => {
aceEditor?.session.setMode('ace/mode/python')
})
} else if (props.type === 'JSON') {
import('ace-builds/src-noconflict/mode-json').then(() => {
aceEditor?.session.setMode('ace/mode/json')
})
} else {
import('ace-builds/src-noconflict/mode-html').then(() => {
aceEditor?.session.setMode('ace/mode/html')
})
}
aceEditor.on('blur', () => {
try {
let value = aceEditor?.getValue() || ''
if (props.type === 'JSON') {
value = JSON.parse(value)
}
if (value === props.modelValue) return
if (!props.showSaveButton && !props.readonly) {
emit('update:modelValue', value)
}
} catch (e) {
// do nothing
}
})
}
const getModelValue = () => {
let value = props.modelValue || ''
try {
if (props.type === 'JSON' || typeof value === 'object') {
value = JSON.stringify(value, null, 2)
}
} catch (e) {
// do nothing
}
return value as string
}
function resetEditor(value: string, resetHistory = false) {
value = getModelValue()
aceEditor?.setValue(value)
aceEditor?.clearSelection()
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
props.autofocus && aceEditor?.focus()
if (resetHistory) {
aceEditor?.session.getUndoManager().reset()
}
}
watch(isDark, () => {
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
})
watch(
() => props.type,
() => {
setupEditor()
}
)
watch(
() => props.modelValue,
() => {
resetEditor(props.modelValue as string)
}
)
defineExpose({ resetEditor })
</script>
<style scoped>
.editor .ace_editor {
height: 100%;
width: 100%;
border-radius: 5px;
overscroll-behavior: none;
}
.editor :deep(.ace_scrollbar-h) {
display: none;
}
.editor :deep(.ace_search) {
@apply dark:bg-gray-800 dark:text-gray-200;
@apply dark:border-gray-800;
}
.editor :deep(.ace_searchbtn) {
@apply dark:bg-gray-800 dark:text-gray-200;
@apply dark:border-gray-800;
}
.editor :deep(.ace_button) {
@apply dark:bg-gray-800 dark:text-gray-200;
}
.editor :deep(.ace_search_field) {
@apply dark:bg-gray-900 dark:text-gray-200;
@apply dark:border-gray-800;
}
</style>

View File

@@ -2,6 +2,7 @@
<div>
<label class="block mb-1" :class="labelClasses" v-if="label">
{{ label }}
<span class="text-red-500" v-if="required">*</span>
</label>
<div class="grid grid-cols-3 gap-1">
<Button
@@ -115,6 +116,9 @@ const props = defineProps({
type: Function,
default: (value) => `${value} is an Invalid value`,
},
required: {
type: Boolean,
},
})
const values = defineModel()
@@ -152,24 +156,11 @@ const filterOptions = createResource({
url: 'frappe.desk.search.search_link',
method: 'POST',
cache: [text.value, props.doctype],
auto: true,
params: {
txt: text.value,
doctype: props.doctype,
},
/* transform: (data) => {
let allData = data
.filter((c) => {
return c.description.split(', ')[1]
})
.map((option) => {
let email = option.description.split(', ')[1]
return {
label: option.label || email,
value: email,
}
})
return allData
}, */
})
const options = computed(() => {

View File

@@ -21,7 +21,7 @@
<script setup>
import { Star } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
const props = defineProps({
id: {

View File

@@ -30,29 +30,29 @@
</div>
<div class="flex flex-col flex-auto p-4">
<div class="flex items-center justify-between mb-2">
<div v-if="course.lesson_count">
<div v-if="course.lessons">
<Tooltip :text="__('Lessons')">
<span class="flex items-center">
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.lesson_count }}
{{ course.lessons }}
</span>
</Tooltip>
</div>
<div v-if="course.enrollment_count">
<div v-if="course.enrollments">
<Tooltip :text="__('Enrolled Students')">
<span class="flex items-center">
<Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.enrollment_count }}
{{ course.enrollments }}
</span>
</Tooltip>
</div>
<div v-if="course.avg_rating">
<div v-if="course.rating">
<Tooltip :text="__('Average Rating')">
<span class="flex items-center">
<Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
{{ course.avg_rating }}
{{ course.rating }}
</span>
</Tooltip>
</div>

View File

@@ -93,21 +93,19 @@
<div class="flex items-center mb-3">
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
<span class="ml-2">
{{ course.data.lesson_count }} {{ __('Lessons') }}
{{ course.data.lessons }} {{ __('Lessons') }}
</span>
</div>
<div class="flex items-center mb-3">
<Users class="h-5 w-5 stroke-1.5 text-gray-600" />
<span class="ml-2">
{{ course.data.enrollment_count_formatted }}
{{ formatAmount(course.data.enrollments) }}
{{ __('Enrolled Students') }}
</span>
</div>
<div class="flex items-center">
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
<span class="ml-2">
{{ course.data.avg_rating }} {{ __('Rating') }}
</span>
<span class="ml-2"> {{ course.data.rating }} {{ __('Rating') }} </span>
</div>
</div>
</div>
@@ -116,7 +114,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 { showToast, formatAmount } from '@/utils/'
import { capture } from '@/telemetry'
import { useRouter } from 'vue-router'
@@ -139,11 +137,11 @@ const video_link = computed(() => {
function enrollStudent() {
if (!user.data) {
createToast({
title: 'Please Login',
icon: 'alert-circle',
iconClasses: 'text-yellow-600 bg-yellow-100',
})
showToast(
__('Please Login'),
__('You need to login first to enroll for this course'),
'circle-warn'
)
setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 2000)
@@ -159,11 +157,11 @@ function enrollStudent() {
capture('enrolled_in_course', {
course: props.course.data.name,
})
createToast({
title: 'Enrolled Successfully',
icon: 'check',
iconClasses: 'text-green-600 bg-green-100',
})
showToast(
__('Success'),
__('You have been enrolled in this course'),
'check'
)
setTimeout(() => {
router.push({
name: 'Lesson',
@@ -173,7 +171,7 @@ function enrollStudent() {
lessonNumber: 1,
},
})
}, 3000)
}, 2000)
})
}
}
@@ -206,7 +204,6 @@ const certificate = createResource({
}
},
onSuccess(data) {
console.log(data)
window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
data.name

View File

@@ -76,7 +76,7 @@
<Trash2
v-if="allowEdit"
@click.prevent="trashLesson(lesson.name, chapter.name)"
class="h-4 w-4 stroke-1.5 text-gray-700 ml-auto invisible group-hover:visible"
class="h-4 w-4 text-red-500 ml-auto invisible group-hover:visible"
/>
<Check
v-if="lesson.is_complete"
@@ -119,7 +119,7 @@
</template>
<script setup>
import { Button, createResource } from 'frappe-ui'
import { ref } from 'vue'
import { ref, getCurrentInstance } from 'vue'
import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import {
@@ -138,6 +138,8 @@ const route = useRoute()
const expandAll = ref(true)
const showChapterModal = ref(false)
const currentChapter = ref(null)
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
const props = defineProps({
courseName: {
@@ -202,9 +204,23 @@ const updateLessonIndex = createResource({
})
const trashLesson = (lessonName, chapterName) => {
deleteLesson.submit({
lesson: lessonName,
chapter: chapterName,
$dialog({
title: __('Delete Lesson'),
message: __('Are you sure you want to delete this lesson?'),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick(close) {
deleteLesson.submit({
lesson: lessonName,
chapter: chapterName,
})
close()
},
},
],
})
}

View File

@@ -76,7 +76,7 @@ const props = defineProps({
required: true,
},
avg_rating: {
type: Number,
type: String,
required: true,
},
membership: {

View File

@@ -37,7 +37,7 @@
<iframe
:src="getPDFSource(block)"
width="100%"
height="400"
height="700px"
frameborder="0"
allowfullscreen
></iframe>

View File

@@ -21,7 +21,7 @@
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
class="flex text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('upload')"
>
<span class="leading-5">
@@ -56,6 +56,21 @@
}}
</div>
</div>
<div class="space-y-2">
<div class="flex items-center text-sm font-medium space-x-2">
<span>
{{ __('What does include in preview mean?') }}
</span>
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
)
}}
</div>
</div>
</div>
<ExplanationVideos v-model="showExplanation" :type="type" />
</template>

View File

@@ -1,33 +1,30 @@
<template>
<Button
v-if="user.data.is_moderator"
variant="solid"
class="float-right mb-5"
@click="openLiveClassModal"
>
<template #prefix>
<Plus class="h-4 w-4" />
</template>
<span>
{{ __('Add Live Class') }}
</span>
</Button>
<div class="text-lg font-semibold mb-5">
{{ __('Live Class') }}
<div class="flex items-center justify-between mb-5">
<div class="text-lg font-semibold">
{{ __('Live Class') }}
</div>
<Button v-if="user.data.is_moderator" @click="openLiveClassModal">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
<span>
{{ __('Add') }}
</span>
</Button>
</div>
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
<div
v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full p-3"
class="flex flex-col border rounded-md h-full text-sm text-gray-700 p-3"
>
<div class="font-semibold text-lg mb-4">
<div class="font-semibold text-gray-900 text-lg mb-4">
{{ cls.title }}
</div>
<div class="mb-4">
<div class="leading-5 text-gray-700 text-sm mb-4">
{{ cls.description }}
</div>
<div class="flex items-center mb-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<Calendar class="w-4 h-4 stroke-1.5 text-gray-700" />
<span class="ml-2">
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
@@ -38,8 +35,9 @@
{{ formatTime(cls.time) }}
</span>
</div>
<div class="flex items-center space-x-2 mt-auto">
<div class="flex items-center space-x-2 text-gray-900 mt-auto">
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
@@ -50,7 +48,7 @@
<a
:href="cls.join_url"
target="_blank"
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
@@ -90,7 +88,6 @@ const liveClasses = createListResource({
doctype: 'LMS Live Class',
filters: {
batch_name: props.batch,
date: ['>=', new Date()],
},
fields: [
'title',

View File

@@ -5,9 +5,11 @@
</div>
<div
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"
class="fixed flex items-center justify-around border-t border-gray-300 bottom-0 z-10 w-full bg-white standalone:pb-4"
:style="{
gridTemplateColumns: `repeat(${sidebarLinks.length}, minmax(0, 1fr))`,
gridTemplateColumns: `repeat(${
sidebarLinks.length + 1
}, minmax(0, 1fr))`,
}"
>
<button
@@ -23,15 +25,46 @@
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
/>
</button>
<Popover
trigger="hover"
popoverClass="bottom-28 mx-2"
placement="top-start"
>
<template #target>
<component
:is="icons['List']"
class="h-6 w-6 stroke-1.5 text-gray-600"
/>
</template>
<template #body-main>
<div class="text-base p-5 space-y-4">
<div
v-for="link in otherLinks"
:key="link.label"
class="flex items-center space-x-2"
@click="handleClick(link)"
>
<component
:is="icons[link.icon]"
class="h-4 w-4 stroke-1.5 text-gray-600"
/>
<div>
{{ link.label }}
</div>
</div>
</div>
</template>
</Popover>
</div>
</div>
</template>
<script setup>
import { getSidebarLinks } from '../utils'
import { useRouter } from 'vue-router'
import { computed, ref, onMounted } from 'vue'
import { watch, ref, onMounted } from 'vue'
import { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/user'
import { Popover } from 'frappe-ui'
import * as icons from 'lucide-vue-next'
const { logout, user, sidebarSettings } = sessionStore()
@@ -39,6 +72,7 @@ let { isLoggedIn } = sessionStore()
const router = useRouter()
let { userResource } = usersStore()
const sidebarLinks = ref(getSidebarLinks())
const otherLinks = ref([])
onMounted(() => {
sidebarSettings.reload(
@@ -52,37 +86,53 @@ onMounted(() => {
)
}
})
addAccessLinks()
addOtherLinks()
},
}
)
})
const addAccessLinks = () => {
const addOtherLinks = () => {
if (user) {
sidebarLinks.value.push({
otherLinks.value.push({
label: 'Notifications',
icon: 'Bell',
to: 'Notifications',
})
otherLinks.value.push({
label: 'Profile',
icon: 'UserRound',
activeFor: [
'Profile',
'ProfileAbout',
'ProfileCertification',
'ProfileEvaluator',
'ProfileRoles',
],
})
sidebarLinks.value.push({
otherLinks.value.push({
label: 'Log out',
icon: 'LogOut',
})
} else {
sidebarLinks.value.push({
otherLinks.value.push({
label: 'Log in',
icon: 'LogIn',
})
}
}
watch(userResource, () => {
if (
userResource.data &&
(userResource.data.is_moderator || userResource.data.is_instructor)
) {
addQuizzes()
}
})
const addQuizzes = () => {
otherLinks.value.push({
label: 'Quizzes',
icon: 'CircleHelp',
to: 'Quizzes',
})
}
let isActive = (tab) => {
return tab.activeFor?.includes(router.currentRoute.value.name)
}

View File

@@ -44,7 +44,7 @@
<script setup>
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
import { reactive } from 'vue'
import { createToast } from '@/utils/'
import { showToast } from '@/utils/'
const show = defineModel()
@@ -94,22 +94,14 @@ const makeAnnouncement = (close) => {
},
onSuccess() {
close()
createToast({
title: 'Success',
text: 'Announcement has been sent successfully',
icon: 'Check',
iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
showToast(
__('Success'),
__('Announcement has been sent successfully'),
'check'
)
},
onError(err) {
createToast({
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-red-600 text-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
showToast(__('Error'), __(err.messages?.[0] || err), 'check')
},
}
)

View File

@@ -2,11 +2,11 @@
<Dialog
v-model="show"
:options="{
title: __('Add Chapter'),
title: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
size: 'lg',
actions: [
{
label: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
label: chapterDetail ? __('Edit') : __('Create'),
variant: 'solid',
onClick: (close) =>
chapterDetail ? editChapter(close) : addChapter(close),

View File

@@ -131,10 +131,16 @@ function submitEvaluation(close) {
},
onError(err) {
let message = err.messages?.[0] || err
let unavailabilityMessage = message.includes('unavailable')
let unavailabilityMessage
if (typeof message === 'string') {
unavailabilityMessage = message?.includes('unavailable')
} else {
unavailabilityMessage = false
}
createToast({
title: unavailabilityMessage ? 'Evaluator is Unavailable' : 'Error',
title: unavailabilityMessage ? __('Evaluator is Unavailable') : '',
text: message,
icon: unavailabilityMessage ? 'alert-circle' : 'x',
iconClasses: 'bg-yellow-600 text-white rounded-md p-px',

View File

@@ -27,8 +27,8 @@ const props = defineProps({
})
const file = computed(() => {
if (props.type == 'youtube') return '/Youtube.mp4'
if (props.type == 'quiz') return '/Quiz.mp4'
if (props.type == 'upload') return '/Upload.mp4'
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
})
</script>

View File

@@ -54,7 +54,7 @@
:label="__('Type')"
v-model="question.type"
type="select"
:options="['Choices', 'User Input']"
:options="['Choices', 'User Input', 'Open Ended']"
class="pb-2"
/>
<div v-if="question.type == 'Choices'" class="divide-y border-t">
@@ -74,7 +74,11 @@
/>
</div>
</div>
<div v-else v-for="n in 4" class="space-y-2">
<div
v-else-if="question.type == 'User Input'"
v-for="n in 4"
class="space-y-2"
>
<FormControl
:label="__('Possibility') + ' ' + n"
v-model="question[`possibility_${n}`]"

View File

@@ -6,7 +6,7 @@
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold">
{{ __('Settings') }}
</h1>
<div v-for="tab in tabs">
<div v-for="tab in tabs" :key="tab.label">
<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"
@@ -17,6 +17,7 @@
<SidebarLink
v-for="item in tab.items"
:link="item"
:key="item.label"
class="w-full"
:class="
activeTab?.label == item.label
@@ -30,7 +31,8 @@
</div>
<div
v-if="activeTab && data.doc"
class="flex flex-1 flex-col px-10 pt-8"
:key="activeTab.label"
class="flex flex-1 flex-col px-10 py-8"
>
<Members
v-if="activeTab.label === 'Members'"
@@ -38,6 +40,25 @@
:description="activeTab.description"
v-model:show="show"
/>
<Categories
v-else-if="activeTab.label === 'Categories'"
:label="activeTab.label"
:description="activeTab.description"
/>
<PaymentSettings
v-else-if="activeTab.label === 'Payment Gateway'"
:label="activeTab.label"
:description="activeTab.description"
:data="data"
:fields="activeTab.fields"
/>
<BrandSettings
v-else-if="activeTab.label === 'Branding'"
:label="activeTab.label"
:description="activeTab.description"
:fields="activeTab.fields"
:data="branding"
/>
<SettingDetails
v-else
:fields="activeTab.fields"
@@ -51,15 +72,20 @@
</Dialog>
</template>
<script setup>
import { Dialog, createDocumentResource } from 'frappe-ui'
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
import { ref, computed, watch } from 'vue'
import { useSettings } from '@/stores/settings'
import SettingDetails from '../SettingDetails.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import Members from '@/components/Members.vue'
import Categories from '@/components/Categories.vue'
import BrandSettings from '@/components/BrandSettings.vue'
import PaymentSettings from '@/components/PaymentSettings.vue'
const show = defineModel()
const doctype = ref('LMS Settings')
const activeTab = ref(null)
const settingsStore = useSettings()
const data = createDocumentResource({
doctype: doctype.value,
@@ -69,8 +95,14 @@ const data = createDocumentResource({
auto: true,
})
const tabs = computed(() => {
let _tabs = [
const branding = createResource({
url: 'lms.lms.api.get_branding',
auto: true,
cache: 'brand',
})
const tabsStructure = computed(() => {
return [
{
label: 'Settings',
hideLabel: true,
@@ -80,6 +112,12 @@ const tabs = computed(() => {
description: 'Manage the members of your learning system',
icon: 'UserRoundPlus',
},
],
},
{
label: 'Settings',
hideLabel: true,
items: [
{
label: 'Payment Gateway',
icon: 'DollarSign',
@@ -87,14 +125,10 @@ const tabs = computed(() => {
'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: 'Payment Gateway',
name: 'payment_gateway',
type: 'Link',
doctype: 'Payment Gateway',
},
{
label: 'Default Currency',
@@ -102,9 +136,6 @@ const tabs = computed(() => {
type: 'Link',
doctype: 'Currency',
},
{
type: 'Column Break',
},
{
label: 'Apply GST for India',
name: 'apply_gst',
@@ -128,6 +159,60 @@ const tabs = computed(() => {
label: 'Settings',
hideLabel: true,
items: [
{
label: 'Categories',
description: 'Manage the members of your learning system',
icon: 'Network',
},
],
},
{
label: 'Customise',
hideLabel: false,
items: [
{
label: 'Branding',
icon: 'Blocks',
fields: [
{
label: 'Brand Name',
name: 'app_name',
type: 'text',
},
{
label: 'Logo',
name: 'banner_image',
type: 'Upload',
},
{
label: 'Favicon',
name: 'favicon',
type: 'Upload',
},
{
label: 'Footer Logo',
name: 'footer_logo',
type: 'Upload',
},
{
label: 'Address',
name: 'address',
type: 'textarea',
rows: 2,
},
{
label: 'Footer "Powered By"',
name: 'footer_powered',
type: 'textarea',
rows: 4,
},
{
label: 'Copyright',
name: 'copyright',
type: 'text',
},
],
},
{
label: 'Sidebar',
icon: 'PanelLeftIcon',
@@ -168,16 +253,9 @@ const tabs = computed(() => {
},
],
},
],
},
{
label: 'Settings',
hideLabel: true,
items: [
{
label: 'Email Templates',
icon: 'MailPlus',
description: 'Create email templates with the content you want',
fields: [
{
label: 'Batch Confirmation Template',
@@ -199,81 +277,51 @@ const tabs = computed(() => {
},
],
},
],
},
{
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: 'Custom Content',
name: 'custom_signup_content',
type: 'Code',
mode: 'htmlmixed',
rows: 10,
},
{
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',
label: 'Ask for Occupation',
name: 'user_category',
type: 'checkbox',
description:
'Enable this option to ask users to select their occupation during the signup process.',
},
],
},
],
},
]
})
return _tabs.map((tab) => {
tab.items = tab.items.filter((item) => {
if (item.condition) {
return item.condition()
}
return true
})
return tab
const tabs = computed(() => {
return tabsStructure.value.map((tab) => {
return {
...tab,
items: tab.items.filter((item) => {
return !item.condition || item.condition()
}),
}
})
})
watch(show, () => {
watch(show, async () => {
if (show.value) {
activeTab.value = tabs.value[0].items[0]
const currentTab = await tabs.value
.flatMap((tab) => tab.items)
.find((item) => item.label === settingsStore.activeTab)
activeTab.value = currentTab || tabs.value[0].items[0]
} else {
activeTab.value = null
settingsStore.isSettingsOpen = false
}
})
</script>

View File

@@ -0,0 +1,109 @@
<template>
<div class="flex flex-col h-full">
<div class="flex items-center justify-between">
<div class="text-xl font-semibold mb-1">
{{ label }}
</div>
<!-- <Badge
v-if="isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/> -->
</div>
<div class="overflow-y-scroll">
<div class="flex space-x-4">
<SettingFields :fields="fields" :data="data.doc" class="w-1/2" />
<SettingFields
v-if="paymentGateway.data"
:fields="paymentGateway.data.fields"
:data="paymentGateway.data.data"
class="w-1/2"
/>
</div>
</div>
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" @click="update">
{{ __('Update') }}
</Button>
</div>
</div>
</template>
<script setup>
import SettingFields from '@/components/SettingFields.vue'
import { createResource, Badge, Button } from 'frappe-ui'
import { watch, ref } from 'vue'
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
data: {
type: Object,
required: true,
},
fields: {
type: Array,
required: true,
},
})
const paymentGateway = createResource({
url: 'lms.lms.api.get_payment_gateway_details',
makeParams(values) {
return {
payment_gateway: props.data.doc.payment_gateway,
}
},
auto: true,
})
const saveSettings = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
let fields = {}
Object.keys(paymentGateway.data.data).forEach((key) => {
if (
paymentGateway.data.data[key] &&
typeof paymentGateway.data.data[key] === 'object'
) {
fields[key] = paymentGateway.data.data[key].file_url
} else {
fields[key] = paymentGateway.data.data[key]
}
})
return {
doctype: paymentGateway.data.doctype,
name: paymentGateway.data.docname,
fieldname: fields,
}
},
auto: false,
onSuccess(data) {
paymentGateway.reload()
},
})
const update = () => {
props.fields.forEach((f) => {
if (f.type != 'Column Break') {
props.data.doc[f.name] = f.value
}
})
props.data.save.submit()
saveSettings.submit()
}
watch(
() => props.data.doc.payment_gateway,
() => {
paymentGateway.reload()
}
)
</script>

View File

@@ -1,11 +1,27 @@
<template>
<div v-if="quiz.data">
<div class="bg-blue-100 py-2 px-2 mb-4 rounded-md text-sm text-blue-800">
<div class="leading-relaxed">
<div
class="bg-blue-100 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-blue-800"
>
<div class="leading-5">
{{
__('This quiz consists of {0} questions.').format(questions.length)
}}
</div>
<div v-if="quiz.data?.duration" class="leading-5">
{{
__(
'Please ensure that you complete all the questions in {0} minutes.'
).format(quiz.data.duration)
}}
</div>
<div v-if="quiz.data?.duration" class="leading-5">
{{
__(
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
)
}}
</div>
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
{{
__(
@@ -22,14 +38,16 @@
)
}}
</div>
<div v-if="quiz.data.time" class="leading-relaxed">
{{
__(
'The quiz has a time limit. For each question you will be given {0} seconds.'
).format(quiz.data.time)
}}
</div>
</div>
<div v-if="quiz.data.duration" class="flex items-center space-x-2 my-4">
<span class="text-gray-600 text-xs"> {{ __('Time') }}: </span>
<ProgressBar :progress="timerProgress" />
<span class="font-semibold">
{{ formatTimer(timer) }}
</span>
</div>
<div v-if="activeQuestion == 0">
<div class="border text-center p-20 rounded-md">
<div class="font-semibold text-lg">
@@ -63,19 +81,12 @@
class="border rounded-md p-5"
>
<div class="flex justify-between">
<div class="text-sm">
<div class="text-sm text-gray-600">
<span class="mr-2">
{{ __('Question {0}').format(activeQuestion) }}:
</span>
<span v-if="questionDetails.data.type == 'User Input'">
{{ __('Type your answer') }}
</span>
<span v-else>
{{
questionDetails.data.multiple
? __('Choose all answers that apply')
: __('Choose one answer')
}}
<span>
{{ getInstructions(questionDetails.data) }}
</span>
</div>
<div class="text-gray-900 text-sm font-semibold item-left">
@@ -139,7 +150,7 @@
{{ questionDetails.data[`explanation_${index}`] }}
</div>
</div>
<div v-else>
<div v-else-if="questionDetails.data.type == 'User Input'">
<FormControl
v-model="possibleAnswer"
type="textarea"
@@ -159,8 +170,18 @@
</Badge>
</div>
</div>
<div class="flex items-center justify-between mt-5">
<div>
<div v-else>
<TextEditor
class="mt-4"
:content="possibleAnswer"
@change="(val) => (possibleAnswer = 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>
<div class="flex items-center justify-between mt-4">
<div class="text-sm text-gray-600">
{{
__('Question {0} of {1}').format(
activeQuestion,
@@ -169,7 +190,11 @@
}}
</div>
<Button
v-if="quiz.data.show_answers && !showAnswers.length"
v-if="
quiz.data.show_answers &&
!showAnswers.length &&
questionDetails.data.type != 'Open Ended'
"
@click="checkAnswer()"
>
<span>
@@ -193,11 +218,18 @@
</div>
</div>
</div>
<div v-else class="border rounded-md p-20 text-center">
<div v-else class="border rounded-md p-20 text-center space-y-4">
<div class="text-lg font-semibold">
{{ __('Quiz Summary') }}
</div>
<div>
<div v-if="quizSubmission.data.is_open_ended">
{{
__(
"Your submission has been successfully saved. The instructor will review and grade it shortly, and you'll be notified of your final result."
)
}}
</div>
<div v-else>
{{
__(
'You got {0}% correct answers with a score of {1} out of {2}'
@@ -236,20 +268,29 @@
</div>
</template>
<script setup>
import { Badge, Button, createResource, ListView } from 'frappe-ui'
import { ref, watch, reactive, inject } from 'vue'
import {
Badge,
Button,
createResource,
ListView,
TextEditor,
FormControl,
} from 'frappe-ui'
import { ref, watch, reactive, inject, computed } from 'vue'
import { createToast } from '@/utils/'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import { timeAgo } from '@/utils'
import FormControl from 'frappe-ui/src/components/FormControl.vue'
const user = inject('$user')
import ProgressBar from '@/components/ProgressBar.vue'
const user = inject('$user')
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 timer = ref(0)
let timerInterval = null
const props = defineProps({
quizName: {
@@ -270,6 +311,7 @@ const quiz = createResource({
auto: true,
onSuccess(data) {
populateQuestions()
setupTimer()
},
})
@@ -285,6 +327,37 @@ const populateQuestions = () => {
}
}
const setupTimer = () => {
if (quiz.data.duration) {
timer.value = quiz.data.duration * 60
}
}
const startTimer = () => {
timerInterval = setInterval(() => {
timer.value--
if (timer.value == 0) {
clearInterval(timerInterval)
submitQuiz()
}
}, 1000)
}
const formatTimer = (seconds) => {
const hrs = Math.floor(seconds / 3600)
.toString()
.padStart(2, '0')
const mins = Math.floor((seconds % 3600) / 60)
.toString()
.padStart(2, '0')
const secs = (seconds % 60).toString().padStart(2, '0')
return hrs != '00' ? `${hrs}:${mins}:${secs}` : `${mins}:${secs}`
}
const timerProgress = computed(() => {
return (timer.value / (quiz.data.duration * 60)) * 100
})
const shuffleArray = (array) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
@@ -369,6 +442,7 @@ watch(
const startQuiz = () => {
activeQuestion.value = 1
localStorage.removeItem(quiz.data.title)
if (quiz.data.duration) startTimer()
}
const markAnswer = (index) => {
@@ -450,9 +524,10 @@ const addToLocalStorage = () => {
}
const nextQuetion = () => {
if (!quiz.data.show_answers) {
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
checkAnswer()
} else {
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
resetQuestion()
}
}
@@ -467,7 +542,8 @@ const resetQuestion = () => {
const submitQuiz = () => {
if (!quiz.data.show_answers) {
checkAnswer()
if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
else checkAnswer()
setTimeout(() => {
createSubmission()
}, 500)
@@ -477,9 +553,15 @@ const submitQuiz = () => {
}
const createSubmission = () => {
quizSubmission.reload().then(() => {
if (quiz.data && quiz.data.max_attempts) attempts.reload()
})
quizSubmission.submit(
{},
{
onSuccess(data) {
if (quiz.data && quiz.data.max_attempts) attempts.reload()
if (quiz.data.duration) clearInterval(timerInterval)
},
}
)
}
const resetQuiz = () => {
@@ -488,6 +570,14 @@ const resetQuiz = () => {
showAnswers.length = 0
quizSubmission.reset()
populateQuestions()
setupTimer()
}
const getInstructions = (question) => {
if (question.type == 'Choices')
if (question.multiple) return __('Choose all answers that apply')
else return __('Choose one answer')
else return __('Type your answer')
}
const getSubmissionColumns = () => {

View File

@@ -1,34 +1,23 @@
<template>
<div class="flex flex-col justify-between h-full">
<div>
<div class="font-semibold mb-1">
{{ __(label) }}
<div class="flex itemsc-center justify-between">
<div class="text-xl font-semibold leading-none mb-1">
{{ __(label) }}
</div>
<Badge
v-if="data.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</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>
<SettingFields :fields="fields" :data="data.doc" />
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }}
@@ -38,9 +27,8 @@
</template>
<script setup>
import { FormControl, Button } from 'frappe-ui'
import { computed } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { Button, Badge } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue'
const props = defineProps({
fields: {
@@ -60,37 +48,23 @@ const props = defineProps({
},
})
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
if (f.type != 'Column Break') {
props.data.doc[f.name] = f.value
}
})
props.data.save.submit()
}
</script>
<style>
.CodeMirror pre.CodeMirror-line,
.CodeMirror pre.CodeMirror-line-like {
font-family: revert;
}
.CodeMirror {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<div
class="my-5"
:class="{ 'flex justify-between w-full': columns.length > 1 }"
>
<div v-for="(column, index) in columns" :key="index">
<div
class="flex flex-col space-y-5"
:class="columns.length > 1 ? 'w-72' : 'w-full'"
>
<div v-for="field in column">
<Link
v-if="field.type == 'Link'"
v-model="data[field.name]"
:doctype="field.doctype"
:label="__(field.label)"
/>
<div v-else-if="field.type == 'Code'">
<CodeEditor
:label="__(field.label)"
type="HTML"
description="The HTML you add here will be shown on your sign up page."
v-model="data[field.name]"
height="250px"
class="shrink-0"
:showLineNumbers="true"
>
</CodeEditor>
</div>
<div v-else-if="field.type == 'Upload'">
<div class="text-sm text-gray-600 mb-1">
{{ __(field.label) }}
</div>
<FileUploader
v-if="!data[field.name]"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => (data[field.name] = file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload an image'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else>
<div class="flex items-center text-sm space-x-2">
<div
class="flex items-center justify-center rounded border border-outline-gray-1 w-[15rem] py-5"
>
<img :src="data[field.name]?.file_url" class="h-6 rounded" />
</div>
<div class="flex flex-col flex-wrap">
<span class="break-all">
{{ data[field.name]?.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(data[field.name]?.file_size) }}
</span>
</div>
<X
@click="data[field.name] = null"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
</div>
<Switch
v-else-if="field.type == 'checkbox'"
size="sm"
:label="__(field.label)"
:description="__(field.description)"
v-model="data[field.name]"
/>
<FormControl
v-else
:key="field.name"
v-model="data[field.name]"
:label="__(field.label)"
:type="field.type"
:rows="field.rows"
:options="field.options"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
import { computed } from 'vue'
import { getFileSize, validateFile } from '@/utils'
import { X, FileText } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue'
import CodeEditor from '@/components/Controls/CodeEditor.vue'
const props = defineProps({
fields: {
type: Array,
required: true,
},
data: {
type: Object,
required: true,
},
})
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[field.name] ? true : false
} else {
field.value = props.data[field.name]
}
currentColumn.push(field)
}
})
if (currentColumn.length > 0) {
cols.push(currentColumn)
}
return cols
})
</script>

View File

@@ -27,7 +27,7 @@
: 'ml-2 w-auto opacity-100'
"
>
{{ link.label }}
{{ __(link.label) }}
</span>
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
{{ link.count }}

View File

@@ -11,11 +11,11 @@
: 'hover:bg-gray-200 px-2 w-52'
"
>
<span
v-if="branding.data?.brand_html"
v-html="branding.data?.brand_html"
<img
v-if="branding.data?.banner_image"
:src="branding.data?.banner_image.file_url"
class="w-8 h-8 rounded flex-shrink-0"
></span>
/>
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
<div
class="flex flex-1 flex-col text-left duration-300 ease-in-out"
@@ -28,11 +28,10 @@
<div class="text-base font-medium text-gray-900 leading-none">
<span
v-if="
branding.data?.brand_name &&
branding.data?.brand_name != 'Frappe'
branding.data?.app_name && branding.data?.app_name != 'Frappe'
"
>
{{ branding.data?.brand_name }}
{{ branding.data?.app_name }}
</span>
<span v-else> Learning </span>
</div>
@@ -67,25 +66,20 @@ 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 { ChevronDown, LogIn, LogOut, User, 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 { useSettings } from '@/stores/settings'
import { markRaw, watch, ref } from 'vue'
import SettingsModal from '@/components/Modals/Settings.vue'
const router = useRouter()
const showSettingsModal = ref(false)
const { logout, branding } = sessionStore()
let { userResource } = usersStore()
const settingsStore = useSettings()
let { isLoggedIn } = sessionStore()
const showSettingsModal = ref(false)
const props = defineProps({
isCollapsed: {
@@ -94,6 +88,13 @@ const props = defineProps({
},
})
watch(
() => settingsStore.isSettingsOpen,
(value) => {
showSettingsModal.value = value
}
)
const userDropdownOptions = [
{
icon: User,
@@ -118,7 +119,7 @@ const userDropdownOptions = [
icon: Settings,
label: 'Settings',
onClick: () => {
showSettingsModal.value = true
settingsStore.isSettingsOpen = true
},
condition: () => {
return userResource.data?.is_moderator

View File

@@ -3,13 +3,14 @@
<video
@timeupdate="updateTime"
@ended="videoEnded"
class="rounded-lg border border-gray-100"
@click="togglePlay"
class="rounded-lg border border-gray-100 group cursor-pointer"
ref="videoRef"
>
<source :src="fileURL" :type="type" />
</video>
<div
class="flex items-center space-x-2 bg-gray-200 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto"
class="flex items-center space-x-2 bg-gray-200 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto invisible group-hover:visible"
>
<Button variant="ghost">
<template #icon>
@@ -106,6 +107,14 @@ const pauseVideo = () => {
playing.value = false
}
const togglePlay = () => {
if (playing.value) {
pauseVideo()
} else {
playVideo()
}
}
const videoEnded = () => {
playing.value = false
}

View File

@@ -5,6 +5,7 @@ import router from './router'
import App from './App.vue'
import { createPinia } from 'pinia'
import dayjs from '@/utils/dayjs'
import { createDialog } from '@/utils/dialogs'
import translationPlugin from './translation'
import { usersStore } from './stores/user'
import { sessionStore } from './stores/session'
@@ -36,3 +37,4 @@ let { isLoggedIn } = sessionStore()
app.provide('$user', userResource)
app.provide('$allUsers', allUsers)
app.config.globalProperties.$user = userResource
app.config.globalProperties.$dialog = createDialog

View File

@@ -236,7 +236,7 @@ const breadcrumbs = computed(() => {
const isStudent = computed(() => {
return (
user?.data &&
batch.data?.students.length &&
batch.data?.students?.length &&
batch.data?.students.includes(user.data.name)
)
})

View File

@@ -32,57 +32,65 @@
</div>
</div>
<div class="mb-4">
<div>
<FileUploader
v-if="!batch.image"
class="mt-4"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="text-xs text-gray-600 mb-1">
{{ __('Meta Image') }}
</div>
<div class="text-xs text-gray-600 mb-2">
{{ __('Meta Image') }}
</div>
<FileUploader
v-if="!batch.image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
<div class="border rounded-md w-fit py-5 px-20">
<Image class="size-5 stroke-1 text-gray-700" />
</div>
<div class="flex flex-col">
<span>
{{ batch.image.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(batch.image.file_size) }}
</span>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-2 text-gray-600 text-sm">
{{
__(
'Appears when the batch URL is shared on any online platform'
)
}}
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img :src="batch.image.file_url" class="border rounded-md w-40" />
<div class="ml-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div class="mt-2 text-gray-600 text-sm">
{{
__(
'Appears when the batch URL is shared on any online platform'
)
}}
</div>
<X
@click="removeImage()"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
</div>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
/>
</div>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
/>
<div class="mb-4">
<FormControl
v-model="batch.description"
:label="__('Description')"
type="textarea"
class="my-4"
:placeholder="__('Short description of the batch')"
/>
<div>
<label class="block text-sm text-gray-600 mb-1">
@@ -133,6 +141,7 @@
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
/>
</div>
@@ -149,6 +158,7 @@
:label="__('Seat Count')"
type="number"
class="mb-4"
:placeholder="__('Number of seats available')"
/>
<FormControl
v-model="batch.evaluation_end_date"
@@ -228,11 +238,11 @@ import {
createResource,
} from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
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 { showToast } from '../utils'
import { Image } from 'lucide-vue-next'
import { capture } from '@/telemetry'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
const router = useRouter()
const user = inject('$user')

View File

@@ -8,12 +8,12 @@
:items="[{ label: __('Batches'), route: { name: 'Batches' } }]"
/>
<div class="flex space-x-2">
<div class="w-40">
<div class="w-44">
<Select
v-if="categories.data?.length"
v-model="currentCategory"
:options="categories.data"
:placeholder="__('Filter')"
:placeholder="__('Category')"
/>
</div>
<router-link
@@ -27,7 +27,7 @@
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('New Batch') }}
{{ __('New') }}
</Button>
</router-link>
</div>
@@ -40,6 +40,7 @@
{{ __('Loading Batches...') }}
</div>
<Tabs
v-if="hasBatches"
v-model="tabIndex"
:tabs="makeTabs"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
@@ -79,24 +80,63 @@
<BatchCard :batch="batch" />
</router-link>
</div>
<div
v-else
class="grid flex-1 place-items-center text-xl font-medium text-gray-500"
>
<div class="flex flex-col items-center justify-center mt-4">
<div>
{{ __('No {0} batches found').format(tab.label.toLowerCase()) }}
</div>
</div>
<div v-else class="p-5 italic text-gray-500">
{{ __('No {0} batches').format(tab.label.toLowerCase()) }}
</div>
</template>
</Tabs>
<div
v-else-if="
!batches.loading &&
!hasBatches &&
(user.data?.is_instructor || user.data?.is_moderator)
"
class="grid grid-cols-3 p-5"
>
<router-link
:to="{
name: 'BatchForm',
params: {
batchName: 'new',
},
}"
>
<div class="bg-gray-50 py-32 px-5 rounded-md">
<div class="flex flex-col items-center text-center space-y-2">
<Plus
class="size-10 stroke-1 text-gray-800 p-1 rounded-full border bg-white"
/>
<div class="font-medium">
{{ __('Create a Batch') }}
</div>
<span class="text-gray-700 text-sm leading-4">
{{ __('You can link courses and assessments to it.') }}
</span>
</div>
</div>
</router-link>
</div>
<div
v-else-if="!batches.loading && !hasBatches"
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
<div class="text-xl font-medium">
{{ __('No batches found') }}
</div>
<div>
{{
__(
'There are no batches available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
createListResource,
createResource,
Breadcrumbs,
Button,
@@ -104,13 +144,14 @@ import {
Badge,
Select,
} from 'frappe-ui'
import { Plus } from 'lucide-vue-next'
import { BookOpen, Plus } from 'lucide-vue-next'
import BatchCard from '@/components/BatchCard.vue'
import { inject, ref, computed, onMounted, watch } from 'vue'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const currentCategory = ref(null)
const hasBatches = ref(false)
onMounted(() => {
let queries = new URLSearchParams(location.search)
@@ -119,10 +160,10 @@ onMounted(() => {
}
})
const batches = createListResource({
const batches = createResource({
doctype: 'LMS Batch',
url: 'lms.lms.utils.get_batches',
cache: ['batches', user?.data?.email],
cache: ['batches', user.data?.email],
auto: true,
})
@@ -183,6 +224,14 @@ const addToTabs = (label) => {
})
}
watch(batches, () => {
Object.keys(batches.data).forEach((key) => {
if (batches.data[key].length) {
hasBatches.value = true
}
})
})
watch(
() => currentCategory.value,
() => {

View File

@@ -1,44 +1,50 @@
<template>
<div class="">
<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
class="h-7"
:items="[{ label: __('Billing Details'), route: { name: 'Billing' } }]"
/>
</header>
<div
v-if="access.data?.access && orderSummary.data"
class="mt-10 w-1/2 mx-auto"
class="pt-5 pb-10 mx-5"
>
<div class="text-3xl font-bold">
{{ __('Billing Details') }}
</div>
<div class="text-gray-600 mt-1">
{{ __('Enter the billing information to complete the payment.') }}
</div>
<div class="border rounded-md p-5 mt-5">
<div class="text-xl font-semibold">
{{ __('Summary') }}
<!-- <div class="mb-5">
<div class="text-lg font-semibold">
{{ __('Address') }}
</div>
<div class="text-gray-600 mt-1">
{{ __('Review the details of your purchase.') }}
</div>
<div class="mt-5">
<div class="flex items-center justify-between">
<div>
</div> -->
<div class="flex flex-col lg:flex-row justify-between">
<div
class="h-fit bg-gray-100 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 text-sm font-medium lg:w-1/4"
>
<div class="flex items-center justify-between space-x-2">
<div class="text-gray-600">
{{ __('Ordered Item') }}
</div>
<div class="">
{{ orderSummary.data.title }}
</div>
<div
:class="{
'font-semibold text-xl': !orderSummary.data.gst_applied,
}"
>
{{
orderSummary.data.gst_applied
? orderSummary.data.original_amount_formatted
: orderSummary.data.total_amount_formatted
}}
</div>
<div
v-if="orderSummary.data.gst_applied"
class="flex items-center justify-between"
>
<div class="text-gray-600">
{{ __('Original Amount') }}
</div>
<div class="">
{{ orderSummary.data.original_amount_formatted }}
</div>
</div>
<div
v-if="orderSummary.data.gst_applied"
class="flex items-center justify-between mt-2"
>
<div>
<div class="text-gray-600">
{{ __('GST Amount') }}
</div>
<div>
@@ -46,107 +52,89 @@
</div>
</div>
<div
v-if="orderSummary.data.gst_applied"
class="flex items-center justify-between mt-2"
class="flex items-center justify-between border-t border-gray-400 pt-4 mt-2"
>
<div>
{{ __('Total Amount') }}
<div class="text-lg font-semibold">
{{ __('Total') }}
</div>
<div class="font-semibold text-2xl">
<div class="text-lg font-semibold">
{{ orderSummary.data.total_amount_formatted }}
</div>
</div>
</div>
<div class="text-xl font-semibold mt-10">
{{ __('Address') }}
</div>
<div class="text-gray-600 mt-1">
{{ __('Specify your billing address correctly.') }}
</div>
<div class="grid grid-cols-2 gap-5 mt-4">
<div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Billing Name') }}
</div>
<Input type="text" v-model="billingDetails.billing_name" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Address Line 1') }}
</div>
<Input type="text" v-model="billingDetails.address_line1" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Address Line 2') }}
</div>
<Input type="text" v-model="billingDetails.address_line2" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('City') }}
</div>
<Input type="text" v-model="billingDetails.city" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('State') }}
</div>
<Input type="text" v-model="billingDetails.state" />
<div class="flex-1 lg:mr-10">
<div class="mb-5">
<div class="text-lg font-semibold">
{{ __('Address') }}
</div>
</div>
<div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Country') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-4">
<FormControl
:label="__('Billing Name')"
v-model="billingDetails.billing_name"
/>
<FormControl
:label="__('Address Line 1')"
v-model="billingDetails.address_line1"
/>
<FormControl
:label="__('Address Line 2')"
v-model="billingDetails.address_line2"
/>
<FormControl :label="__('City')" v-model="billingDetails.city" />
<FormControl
:label="__('State')"
v-model="billingDetails.state"
/>
</div>
<div class="space-y-4">
<Link
doctype="Country"
:value="billingDetails.country"
@change="(option) => changeCurrency(option)"
:label="__('Country')"
/>
<FormControl
:label="__('Postal Code')"
v-model="billingDetails.pincode"
/>
<FormControl
:label="__('Phone Number')"
v-model="billingDetails.phone"
/>
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Postal Code') }}
</div>
<Input type="text" v-model="billingDetails.pincode" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Phone Number') }}
</div>
<Input type="text" v-model="billingDetails.phone" />
</div>
<div class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Source') }}
</div>
<Link
doctype="LMS Source"
:value="billingDetails.source"
@change="(option) => (billingDetails.source = option)"
:label="__('Where did you hear about us?')"
/>
<FormControl
v-if="billingDetails.country == 'India'"
:label="__('GST Number')"
v-model="billingDetails.gstin"
/>
<FormControl
v-if="billingDetails.country == 'India'"
:label="__('Pan Number')"
v-model="billingDetails.pan"
/>
</div>
<div v-if="billingDetails.country == 'India'" class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('GST Number') }}
</div>
<Input type="text" v-model="billingDetails.gstin" />
</div>
<div v-if="billingDetails.country == 'India'" class="mt-4">
<div class="mb-1.5 text-sm text-gray-700">
{{ __('Pan Number') }}
</div>
<Input type="text" v-model="billingDetails.pan" />
</div>
</div>
<div class="flex items-center justify-between border-t pt-4 mt-8">
<p class="text-gray-600">
{{
__(
'Make sure to enter the right billing name as the same will be used in your invoice.'
)
}}
</p>
<Button variant="solid" size="md" @click="generatePaymentLink()">
{{ __('Proceed to Payment') }}
</Button>
</div>
</div>
<Button variant="solid" class="mt-8" @click="generatePaymentLink()">
{{ __('Proceed to Payment') }}
</Button>
</div>
</div>
<div v-else-if="access.data?.message">
@@ -167,11 +155,18 @@
</div>
</template>
<script setup>
import { Input, Button, createResource } from 'frappe-ui'
import {
Input,
Button,
createResource,
FormControl,
Breadcrumbs,
Tooltip,
} from 'frappe-ui'
import { reactive, inject, onMounted, ref } from 'vue'
import Link from '@/components/Controls/Link.vue'
import NotPermitted from '@/components/NotPermitted.vue'
import { createToast } from '@/utils/'
import { showToast } from '@/utils/'
const user = inject('$user')
@@ -202,8 +197,8 @@ const access = createResource({
name: props.name,
},
onSuccess(data) {
orderSummary.submit()
setBillingDetails(data.address)
orderSummary.submit()
},
})
@@ -224,84 +219,49 @@ const orderSummary = createResource({
const billingDetails = reactive({})
const setBillingDetails = (data) => {
billingDetails.billing_name = data.billing_name || ''
billingDetails.address_line1 = data.address_line1 || ''
billingDetails.address_line2 = data.address_line2 || ''
billingDetails.city = data.city || ''
billingDetails.state = data.state || ''
billingDetails.country = data.country || ''
billingDetails.pincode = data.pincode || ''
billingDetails.phone = data.phone || ''
billingDetails.source = data.source || ''
billingDetails.gstin = data.gstin || ''
billingDetails.pan = data.pan || ''
billingDetails.billing_name = data?.billing_name || ''
billingDetails.address_line1 = data?.address_line1 || ''
billingDetails.address_line2 = data?.address_line2 || ''
billingDetails.city = data?.city || ''
billingDetails.state = data?.state || ''
billingDetails.country = data?.country || ''
billingDetails.pincode = data?.pincode || ''
billingDetails.phone = data?.phone || ''
billingDetails.source = data?.source || ''
billingDetails.gstin = data?.gstin || ''
billingDetails.pan = data?.pan || ''
}
const paymentOptions = createResource({
url: 'lms.lms.utils.get_payment_options',
const paymentLink = createResource({
url: 'lms.lms.payments.get_payment_link',
makeParams(values) {
return {
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
docname: props.name,
phone: billingDetails.phone,
country: billingDetails.country,
title: orderSummary.data.title,
amount: orderSummary.data.original_amount,
total_amount: orderSummary.data.amount,
currency: orderSummary.data.currency,
address: billingDetails,
}
},
})
const generatePaymentLink = () => {
paymentOptions.submit(
paymentLink.submit(
{},
{
validate(params) {
validate() {
if (!billingDetails.source) {
return __('Please let us know where you heard about us from.')
}
return validateAddress()
},
onSuccess(data) {
data.handler = (response) => {
let doctype = props.type == 'course' ? 'LMS Course' : 'LMS Batch'
let docname = props.name
handleSuccess(response, doctype, docname, data.order_id)
}
let rzp1 = new Razorpay(data)
rzp1.open()
window.location.href = data
},
onError(err) {
showError(err)
},
}
)
}
const paymentResource = createResource({
url: 'lms.lms.utils.verify_payment',
makeParams(values) {
return {
response: values.response,
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
docname: props.name,
address: billingDetails,
order_id: values.orderId,
}
},
})
const handleSuccess = (response, doctype, docname, orderId) => {
paymentResource.submit(
{
response: response,
orderId: orderId,
},
{
onSuccess(data) {
createToast({
title: 'Success',
text: 'Payment Successful',
icon: 'check',
iconClasses: 'bg-green-600 text-white rounded-md p-px',
})
setTimeout(() => {
window.location.href = data
}, 3000)
showToast(__('Error'), err.messages?.[0] || err, 'x')
},
}
)

View File

@@ -16,16 +16,16 @@
</div>
<div class="flex items-center">
<Tooltip
v-if="course.data.avg_rating"
v-if="course.data.rating"
:text="__('Average Rating')"
class="flex items-center"
>
<Star class="h-5 w-5 text-gray-100 fill-orange-500" />
<span class="ml-1">
{{ course.data.avg_rating }}
{{ course.data.rating }}
</span>
</Tooltip>
<span v-if="course.data.avg_rating" class="mx-3">&middot;</span>
<span v-if="course.data.rating" class="mx-3">&middot;</span>
<Tooltip
v-if="course.data.enrollment_count"
:text="__('Enrolled Students')"
@@ -74,7 +74,7 @@
</div>
<CourseReviews
:courseName="course.data.name"
:avg_rating="course.data.avg_rating"
:avg_rating="course.data.rating"
:membership="course.data.membership"
/>
</div>
@@ -116,7 +116,7 @@ const breadcrumbs = computed(() => {
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
items.push({
label: course?.data?.title,
route: { name: 'CourseDetail', params: { course: course?.data?.name } },
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
})
return items
})

View File

@@ -23,15 +23,23 @@
v-model="course.title"
:label="__('Title')"
class="mb-4"
:required="true"
/>
<FormControl
v-model="course.short_introduction"
:label="__('Short Introduction')"
:placeholder="
__(
'A one line introduction to the course that appears on the course card'
)
"
class="mb-4"
:required="true"
/>
<div class="mb-4">
<div class="mb-1.5 text-sm text-gray-700">
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Course Description') }}
<span class="text-red-500">*</span>
</div>
<TextEditor
:content="course.description"
@@ -41,49 +49,62 @@
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
<FileUploader
v-if="!course.course_image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload an image'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="text-xs text-gray-600 mb-1">
<div class="mb-4">
<div class="text-xs text-gray-600 mb-2">
{{ __('Course Image') }}
<span class="text-red-500">*</span>
</div>
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
<FileUploader
v-if="!course.course_image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="flex items-center">
<div class="border rounded-md w-fit py-5 px-20">
<Image class="size-5 stroke-1 text-gray-700" />
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-2 text-gray-600 text-sm">
{{
__('Appears on the course card in the course list')
}}
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img
:src="course.course_image.file_url"
class="border rounded-md w-40"
/>
<div class="ml-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div class="mt-2 text-gray-600 text-sm">
{{ __('Appears on the course card in the course list') }}
</div>
</div>
</div>
<div class="flex flex-col">
<span>
{{ course.course_image.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(course.course_image.file_size) }}
</span>
</div>
<X
@click="removeImage()"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
<FormControl
v-model="course.video_link"
:label="__('Preview Video')"
:placeholder="
__(
'Paste the youtube link of a short video introducing the course'
)
"
class="mb-4"
/>
<div class="mb-4">
@@ -104,15 +125,26 @@
</div>
<FormControl
v-model="newTag"
:placeholder="__('Keywords for the course')"
class="w-52"
@keyup.enter="updateTags()"
id="tags"
/>
</div>
</div>
<div class="w-1/2 mb-4">
<Link
doctype="LMS Category"
v-model="course.category"
:label="__('Category')"
:onCreate="(value, close) => openSettings(close)"
/>
</div>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
:required="true"
/>
</div>
<div class="container border-t">
@@ -122,7 +154,7 @@
<div class="grid grid-cols-3 gap-10 mb-4">
<div
v-if="user.data?.is_moderator"
class="flex flex-col space-y-3"
class="flex flex-col space-y-4"
>
<FormControl
type="checkbox"
@@ -216,23 +248,20 @@ import {
reactive,
watch,
} from 'vue'
import {
convertToTitleCase,
showToast,
getFileSize,
updateDocumentTitle,
} from '../utils'
import { convertToTitleCase, showToast, updateDocumentTitle } from '@/utils'
import Link from '@/components/Controls/Link.vue'
import { FileText, X } from 'lucide-vue-next'
import { FileText, Image, 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'
import { useSettings } from '@/stores/settings'
const user = inject('$user')
const newTag = ref('')
const router = useRouter()
const instructors = ref([])
const settingsStore = useSettings()
const props = defineProps({
courseName: {
@@ -463,6 +492,12 @@ const removeImage = () => {
course.course_image = null
}
const openSettings = (close) => {
close()
settingsStore.activeTab = 'Categories'
settingsStore.isSettingsOpen = true
}
const check_permission = () => {
let user_is_instructor = false
if (user.data?.is_moderator) return

View File

@@ -8,7 +8,16 @@
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
/>
<div class="flex space-x-2 justify-end">
<div class="w-36">
<div class="w-40 md:w-44">
<FormControl
v-if="categories.data?.length"
type="select"
v-model="currentCategory"
:options="categories.data"
:placeholder="__('Category')"
/>
</div>
<div class="w-28 md:w-36">
<FormControl
type="text"
placeholder="Search"
@@ -32,13 +41,14 @@
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('New Course') }}
{{ __('New') }}
</Button>
</router-link>
</div>
</header>
<div class="">
<Tabs
v-if="hasCourses"
v-model="tabIndex"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
:tabs="makeTabs"
@@ -92,18 +102,57 @@
<CourseCard :course="course" />
</router-link>
</div>
<div
v-else
class="grid flex-1 place-items-center text-xl font-medium text-gray-500"
>
<div class="flex flex-col items-center justify-center mt-4">
<div>
{{ __('No {0} courses found').format(tab.label.toLowerCase()) }}
</div>
</div>
<div v-else class="p-5 italic text-gray-500">
{{ __('No {0} courses').format(tab.label.toLowerCase()) }}
</div>
</template>
</Tabs>
<div
v-else-if="
!courses.loading &&
(user.data?.is_moderator || user.data?.is_instructor)
"
class="grid grid-cols-3 p-5"
>
<router-link
:to="{
name: 'CourseForm',
params: {
courseName: 'new',
},
}"
>
<div class="bg-gray-50 py-32 px-5 rounded-md">
<div class="flex flex-col items-center text-center space-y-2">
<Plus
class="size-10 stroke-1 text-gray-800 p-1 rounded-full border bg-white"
/>
<div class="font-medium">
{{ __('Create a Course') }}
</div>
<span class="text-gray-700 text-sm leading-4">
{{ __('You can add chapters and lessons to it.') }}
</span>
</div>
</div>
</router-link>
</div>
<div
v-else-if="!courses.loading && !hasCourses"
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
<div class="text-xl font-medium">
{{ __('No courses found') }}
</div>
<div>
{{
__(
'There are no courses available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
</div>
</div>
</template>
@@ -118,12 +167,21 @@ import {
createResource,
} from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue'
import { Plus, Search } from 'lucide-vue-next'
import { ref, computed, inject } from 'vue'
import { BookOpen, Plus, Search } from 'lucide-vue-next'
import { ref, computed, inject, onMounted, watch } from 'vue'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const searchQuery = ref('')
const currentCategory = ref(null)
const hasCourses = ref(false)
onMounted(() => {
let queries = new URLSearchParams(location.search)
if (queries.has('category')) {
currentCategory.value = queries.get('category')
}
})
const courses = createResource({
url: 'lms.lms.utils.get_courses',
@@ -168,18 +226,67 @@ const addToTabs = (label) => {
}
const getCourses = (type) => {
let courseList = courses.data[type]
if (searchQuery.value) {
let query = searchQuery.value.toLowerCase()
return courses.data[type].filter(
courseList = courseList.filter(
(course) =>
course.title.toLowerCase().includes(query) ||
course.short_introduction.toLowerCase().includes(query) ||
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
)
}
return courses.data[type]
if (currentCategory.value && currentCategory.value != '') {
courseList = courseList.filter(
(course) => course.category == currentCategory.value
)
}
return courseList
}
const categories = createResource({
url: 'lms.lms.api.get_categories',
makeParams() {
return {
doctype: 'LMS Course',
filters: {
published: 1,
},
}
},
cache: ['courseCategories'],
auto: true,
transform(data) {
data.unshift({
label: '',
value: null,
})
},
})
watch(courses, () => {
if (courses.data) {
Object.keys(courses.data).forEach((section) => {
if (courses.data[section].length) {
hasCourses.value = true
}
})
}
})
watch(
() => currentCategory.value,
() => {
let queries = new URLSearchParams(location.search)
if (currentCategory.value) {
queries.set('category', currentCategory.value)
} else {
queries.delete('category')
}
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
}
)
const pageMeta = computed(() => {
return {
title: 'Courses',

View File

@@ -149,7 +149,7 @@ const newJob = createResource({
return {
doc: {
doctype: 'Job Opportunity',
company_logo: job.image.file_url,
company_logo: job.image?.file_url,
...job,
},
}

View File

@@ -52,46 +52,88 @@
</header>
<div v-if="job.data" class="max-w-3xl mx-auto">
<div class="p-4">
<div class="flex mb-10">
<img
:src="job.data.company_logo"
class="w-16 h-16 rounded-lg object-contain mr-4"
:alt="job.data.company_name"
/>
<div>
<div class="space-y-5 mb-10">
<div class="flex items-center">
<img
:src="job.data.company_logo"
class="w-16 h-16 rounded-lg object-contain mr-4"
:alt="job.data.company_name"
/>
<div class="text-2xl font-semibold mb-4">
{{ job.data.job_title }}
</div>
</div>
<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"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-5 md:gap-y-5"
>
<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="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 class="p-4 bg-green-50 rounded-full">
<Building2 class="h-4 w-4 text-green-500" />
</span>
<div class="flex flex-col space-y-2">
<span class="text-xs text-gray-600 font-medium uppercase">
{{ __('Organisation') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.company_name }}
</span>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="p-4 bg-red-50 rounded-full">
<MapPin class="h-4 w-4 text-red-500" />
</span>
<div class="flex flex-col space-y-2">
<span class="text-xs text-gray-600 font-medium uppercase">
{{ __('Location') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.location }}
</span>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="p-4 bg-yellow-50 rounded-full">
<ClipboardType class="h-4 w-4 text-yellow-500" />
</span>
<div class="flex flex-col space-y-2">
<span class="text-xs font-medium text-gray-600 uppercase">
{{ __('Category') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.type }}
</span>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="p-4 bg-blue-50 rounded-full">
<CalendarDays class="h-4 w-4 text-blue-500" />
</span>
<div class="flex flex-col space-y-2">
<span class="text-xs text-gray-600 font-medium uppercase">
{{ __('Posted on') }}
</span>
<span class="text-sm font-semibold">
{{ dayjs(job.data.creation).format('DD MMM YYYY') }}
</span>
</div>
</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
>
<span class="p-4 bg-purple-50 rounded-full">
<SquareUserRound class="h-4 w-4 text-purple-500" />
</span>
<div class="flex flex-col space-y-2">
<span class="text-xs text-gray-600 font-medium uppercase">
{{ __('Applications Received') }}
</span>
<span class="text-sm font-semibold">
{{ applicationCount.data }}
</span>
</div>
</div>
</div>
</div>

View File

@@ -17,14 +17,9 @@
)
}}
</p>
<router-link
v-if="user.data"
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
>
<Button variant="solid">
{{ __('Start Learning') }}
</Button>
</router-link>
<Button v-if="user.data" @click="enrollStudent()" variant="solid">
{{ __('Start Learning') }}
</Button>
<Button v-else @click="redirectToLogin()">
{{ __('Login') }}
</Button>
@@ -120,6 +115,7 @@
</div>
<div
v-if="
lesson.data.instructor_content &&
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
allowInstructorContent()
"
@@ -193,7 +189,7 @@ import { createResource, Breadcrumbs, Button } from 'frappe-ui'
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
import CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRoute } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import Discussions from '@/components/Discussions.vue'
import { getEditorTools, updateDocumentTitle } from '../utils'
@@ -203,6 +199,7 @@ import CourseInstructors from '@/components/CourseInstructors.vue'
import ProgressBar from '@/components/ProgressBar.vue'
const user = inject('$user')
const router = useRouter()
const route = useRoute()
const allowDiscussions = ref(false)
const editor = ref(null)
@@ -244,7 +241,10 @@ const lesson = createResource({
onSuccess(data) {
lessonProgress.value = data.membership?.progress
if (data.content) editor.value = renderEditor('editor', data.content)
if (JSON.parse(data.instructor_content)?.blocks?.length > 1)
if (
data.instructor_content &&
JSON.parse(data.instructor_content)?.blocks?.length > 1
)
instructorEditor.value = renderEditor(
'instructor-content',
data.instructor_content
@@ -275,7 +275,7 @@ const renderEditor = (holder, content) => {
}
const markProgress = () => {
if (user.data && !lesson.data?.progress) {
if (user.data && lesson.data && !lesson.data.progress) {
progress.submit()
}
}
@@ -297,14 +297,14 @@ const breadcrumbs = computed(() => {
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
items.push({
label: lesson?.data?.course_title,
route: { name: 'CourseDetail', params: { course: props.courseName } },
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
})
items.push({
label: lesson?.data?.title,
route: {
name: 'Lesson',
params: {
course: props.courseName,
courseName: props.courseName,
chapterNumber: props.chapterNumber,
lessonNumber: props.lessonNumber,
},
@@ -375,6 +375,30 @@ const allowInstructorContent = () => {
return false
}
const enrollment = createResource({
url: 'frappe.client.insert',
makeParams() {
return {
doc: {
doctype: 'LMS Enrollment',
course: props.courseName,
member: user.data?.name,
},
}
},
})
const enrollStudent = () => {
enrollment.submit(
{},
{
onSuccess() {
window.location.reload()
},
}
)
}
const redirectToLogin = () => {
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
}

View File

@@ -69,7 +69,7 @@
</div>
</template>
<script setup>
import { Breadcrumbs, FormControl, createResource, Button } from 'frappe-ui'
import { Breadcrumbs, Button, createResource, FormControl } from 'frappe-ui'
import {
computed,
reactive,

View File

@@ -42,7 +42,7 @@
<img
:src="badge.badge_image"
:alt="badge.badge"
class="bg-gray-100 rounded-t-md"
class="bg-gray-100 rounded-t-md h-[200px] mx-auto"
/>
<div class="p-5">
<div class="text-2xl font-semibold mb-2">
@@ -142,7 +142,7 @@ const shareOnSocial = (badge, medium) => {
const summary = `I am happy to announce that I earned the ${
badge.badge
} badge on ${dayjs(badge.issued_on).format('DD MMM YYYY')} at ${
branding.data?.brand_name
branding.data?.app_name
}.`
if (medium == 'LinkedIn')

View File

@@ -3,14 +3,42 @@
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>
<div class="space-x-2">
<router-link
v-if="quizDetails.data?.name"
:to="{
name: 'QuizPage',
params: {
quizID: quizDetails.data.name,
},
}"
>
<Button>
{{ __('Open') }}
</Button>
</router-link>
<router-link
v-if="quizDetails.data?.name"
:to="{
name: 'QuizSubmissionList',
params: {
quizID: quizDetails.data.name,
},
}"
>
<Button>
{{ __('Submission List') }}
</Button>
</router-link>
<Button variant="solid" @click="submitQuiz()">
{{ __('Save') }}
</Button>
</div>
</header>
<div class="w-3/4 mx-auto py-5">
<!-- Details -->
<div class="mb-8">
<div class="text-sm font-semibold mb-4">
<div class="font-semibold mb-4">
{{ __('Details') }}
</div>
<FormControl
@@ -22,11 +50,17 @@
"
/>
<div v-if="quizDetails.data?.name">
<div class="grid grid-cols-3 gap-5 mt-4 mb-8">
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
<FormControl
type="number"
v-model="quiz.max_attempts"
:label="__('Maximun Attempts')"
/>
<FormControl
type="number"
v-model="quiz.duration"
:label="__('Duration (in minutes)')"
/>
<FormControl
v-model="quiz.total_marks"
:label="__('Total Marks')"
@@ -40,7 +74,7 @@
<!-- Settings -->
<div class="mb-8">
<div class="text-sm font-semibold mb-4">
<div class="font-semibold mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-3 gap-5 my-4">
@@ -58,7 +92,7 @@
</div>
<div class="mb-8">
<div class="text-sm font-semibold mb-4">
<div class="font-semibold mb-4">
{{ __('Shuffle Settings') }}
</div>
<div class="grid grid-cols-3">
@@ -78,7 +112,7 @@
<!-- Questions -->
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-sm font-semibold">
<div class="font-semibold">
{{ __('Questions') }}
</div>
<Button @click="openQuestionModal()">
@@ -198,6 +232,7 @@ const quiz = reactive({
total_marks: 0,
passing_percentage: 0,
max_attempts: 0,
duration: 0,
limit_questions_to: 0,
show_answers: true,
show_submission_history: false,
@@ -347,17 +382,17 @@ const questionColumns = computed(() => {
{
label: __('ID'),
key: 'question',
width: '25%',
width: '10rem',
},
{
label: __('Question'),
key: __('question_detail'),
width: '60%',
width: '40rem',
},
{
label: __('Marks'),
key: 'marks',
width: '10%',
width: '5rem',
},
]
})

View File

@@ -0,0 +1,58 @@
<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="md:w-7/12 md:mx-auto mx-4 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'
import { updateDocumentTitle } from '@/utils'
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 }]
})
const pageMeta = computed(() => {
return {
title: title.data?.title,
description: __('Quiz Submission'),
}
})
updateDocumentTitle(pageMeta)
</script>

View File

@@ -2,47 +2,121 @@
<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" />
<Breadcrumbs v-if="submisisonDetails.doc" :items="breadcrumbs" />
<div class="space-x-2">
<Badge
v-if="submisisonDetails.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
<Button variant="solid" @click="saveSubmission()">
{{ __('Save') }}
</Button>
</div>
</header>
<div class="w-1/2 mx-auto py-10">
<Quiz :quizName="quizID" />
<div v-if="submisisonDetails.doc" class="w-1/2 mx-auto py-5 space-y-4">
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="submisisonDetails.doc.quiz_title"
:label="__('Quiz')"
:disabled="true"
/>
<FormControl
v-model="submisisonDetails.doc.member_name"
:label="__('Member')"
:disabled="true"
/>
</div>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="submisisonDetails.doc.score"
:label="__('Score')"
:disabled="true"
/>
<FormControl
v-model="submisisonDetails.doc.percentage"
:label="__('Percentage')"
:disabled="true"
/>
</div>
<div
v-for="row in submisisonDetails.doc.result"
class="border p-5 rounded-md space-y-4"
>
<div class="font-semibold">{{ row.idx }}. {{ row.question }}</div>
<div v-html="row.answer" class="leading-5"></div>
<div class="grid grid-cols-2 gap-5">
<FormControl v-model="row.marks" :label="__('Marks')" />
<FormControl
v-model="row.marks_out_of"
:label="__('Marks out of')"
:disabled="true"
/>
</div>
</div>
</div>
</template>
<script setup>
import Quiz from '@/components/Quiz.vue'
import { createResource, Breadcrumbs } from 'frappe-ui'
import { computed, inject, onMounted } from 'vue'
import {
createDocumentResource,
Breadcrumbs,
FormControl,
Button,
Badge,
} from 'frappe-ui'
import { computed, onMounted, inject } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from '@/utils'
const user = inject('$user')
const router = useRouter()
const user = inject('$user')
onMounted(() => {
if (!user.data) {
if (!user.data?.is_instructor && !user.data?.is_moderator)
router.push({ name: 'Courses' })
}
})
const props = defineProps({
quizID: {
submission: {
type: String,
required: true,
},
})
const title = createResource({
url: 'frappe.client.get_value',
params: {
doctype: 'LMS Quiz',
fieldname: 'title',
filters: {
name: props.quizID,
},
},
const submisisonDetails = createDocumentResource({
doctype: 'LMS Quiz Submission',
name: props.submission,
auto: true,
})
const breadcrumbs = computed(() => {
return [{ label: __('Quiz Submission') }, { label: title.data?.title }]
return [
{
label: __('Quiz Submissions'),
route: {
name: 'QuizSubmissionList',
params: {
quizID: submisisonDetails.doc.quiz,
},
},
},
{
label: submisisonDetails.doc.quiz_title,
},
]
})
const saveSubmission = () => {
submisisonDetails.save.submit(
{},
{
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
</script>

View File

@@ -0,0 +1,104 @@
<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 v-if="submissions.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
<ListView
:columns="quizColumns"
:rows="submissions.data"
row-key="name"
:options="{ showTooltip: false, selectable: false }"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in quizColumns">
</ListHeaderItem>
</ListHeader>
<ListRows>
<router-link
v-for="row in submissions.data"
:to="{
name: 'QuizSubmission',
params: {
submission: row.name,
},
}"
>
<ListRow :row="row" />
</router-link>
</ListRows>
</ListView>
</div>
</template>
<script setup>
import {
createListResource,
Breadcrumbs,
ListView,
ListRow,
ListRows,
ListHeader,
ListHeaderItem,
} from 'frappe-ui'
import { computed, onMounted, inject } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const user = inject('$user')
onMounted(() => {
if (!user.data?.is_instructor && !user.data?.is_moderator)
router.push({ name: 'Courses' })
})
const props = defineProps({
quizID: {
type: String,
required: true,
},
})
const submissions = createListResource({
doctype: 'LMS Quiz Submission',
filters: {
quiz: props.quizID,
},
fields: ['name', 'member_name', 'score', 'percentage', 'quiz_title'],
orderBy: 'creation desc',
auto: true,
})
const quizColumns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
width: 2,
},
{
label: __('Quiz'),
key: 'quiz_title',
width: 2,
},
{
label: __('Score'),
key: 'score',
width: 1,
align: 'center',
},
{
label: __('Percentage'),
key: 'percentage',
width: 1,
align: 'center',
},
]
})
const breadcrumbs = computed(() => {
return [{ label: __('Quiz Submissions') }]
})
</script>

View File

@@ -19,7 +19,7 @@
</Button>
</router-link>
</header>
<div v-if="quizzes.data?.length" class="w-3/4 mx-auto py-5">
<div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
<ListView
:columns="quizColumns"
:rows="quizzes.data"

View File

@@ -160,7 +160,19 @@ const routes = [
},
{
path: '/quiz/:quizID',
name: 'Quiz',
name: 'QuizPage',
component: () => import('@/pages/QuizPage.vue'),
props: true,
},
{
path: '/quiz-submissions/:quizID',
name: 'QuizSubmissionList',
component: () => import('@/pages/QuizSubmissionList.vue'),
props: true,
},
{
path: '/quiz-submission/:submission',
name: 'QuizSubmission',
component: () => import('@/pages/QuizSubmission.vue'),
props: true,
},

View File

@@ -17,7 +17,7 @@ export const sessionStore = defineStore('lms-session', () => {
}
let user = ref(sessionUser())
if (user) {
if (user.value) {
allUsers.reload()
}
const isLoggedIn = computed(() => !!user.value)

View File

@@ -0,0 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useSettings = defineStore('settings', () => {
const isSettingsOpen = ref(false)
const activeTab = ref(null)
return {
isSettingsOpen,
activeTab,
}
})

View File

@@ -57,6 +57,15 @@ export function formatNumberIntoCurrency(number, currency) {
return ''
}
// create a function that formats numbers in thousands to k
export function formatAmount(amount) {
if (amount > 999) {
return (amount / 1000).toFixed(1) + 'k'
}
return amount
}
export function convertToTitleCase(str) {
if (!str) {
return ''
@@ -82,10 +91,13 @@ export function getFileSize(file_size) {
export function showToast(title, text, icon, iconClasses = null) {
if (!iconClasses) {
iconClasses =
icon == 'check'
? 'bg-green-600 text-white rounded-md p-px'
: 'bg-red-600 text-white rounded-md p-px'
if (icon == 'check') {
iconClasses = 'bg-green-600 text-white rounded-md p-px'
} else if (icon == 'circle-warn') {
iconClasses = 'bg-yellow-600 text-white rounded-md p-px'
} else {
iconClasses = 'bg-red-600 text-white rounded-md p-px'
}
}
createToast({
title: title,
@@ -499,3 +511,10 @@ export function singularize(word) {
(r) => endings[r]
)
}
export const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
return __('Only image file is allowed.')
}
}

View File

@@ -51,7 +51,7 @@ export class Quiz {
app.mount(this.wrapper)
return
}
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center mb-2'>
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-gray-50 mb-2'>
<span class="font-medium">
Quiz: ${quiz}
</span>

View File

@@ -56,9 +56,11 @@ export class Upload {
app.mount(this.wrapper)
return
} else if (file.file_type == 'PDF') {
this.wrapper.innerHTML = `<iframe src="${encodeURI(
this.wrapper.innerHTML = `<iframe src="https://docs.google.com/viewer?url=${
window.location.origin
}${encodeURI(
file.file_url
)}#toolbar=0" width='100%' height='700px' class="mb-4"></iframe>`
)}&embedded=true" width='100%' height='700px' class="mb-4" type="application/pdf"></iframe>`
return
} else {
this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI(

2188
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

View File

@@ -1,13 +0,0 @@
from frappe import _
def get_data():
return [
{
"module_name": "Community",
"color": "grey",
"icon": "octicon octicon-file-directory",
"type": "module",
"label": _("Community"),
}
]

View File

@@ -1,12 +0,0 @@
"""
Configuration for docs
"""
# source_link = "https://github.com/[org_name]/community"
# docs_base_url = "https://[org_name].github.io/community"
# headline = "App that does everything"
# sub_heading = "Yes, you got that right the first time, everything"
def get_context(context):
context.brand_html = "Community"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
[
{
"category": "Web Development",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:58:16.841571",
"name": "Web Development"
},
{
"category": "Business",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:58:32.304850",
"name": "Business"
},
{
"category": "Design",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:59:12.621022",
"name": "Design"
},
{
"category": "Personal Development",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:59:19.287404",
"name": "Personal Development"
},
{
"category": "Finance",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:58:28.579714",
"name": "Finance"
},
{
"category": "Frontend",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-05-08 14:05:16.979275",
"name": "Frontend"
},
{
"category": "Framework",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2023-06-15 18:01:41.598282",
"name": "Framework"
}
]

View File

@@ -110,12 +110,13 @@ doc_events = {
# ---------------
scheduler_events = {
"hourly": [
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals"
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals",
"lms.lms.api.update_course_statistics",
],
"daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"],
}
fixtures = ["Custom Field", "Function", "Industry"]
fixtures = ["Custom Field", "Function", "Industry", "LMS Category"]
# Testing
# -------
@@ -185,6 +186,7 @@ jinja = {
"lms.lms.utils.get_lesson_url",
"lms.page_renderers.get_profile_url",
"lms.overrides.user.get_palette",
"lms.lms.utils.is_instructor",
],
"filters": [],
}

View File

@@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestLMSJobApplication(FrappeTestCase):
class TestLMSJobApplication(UnitTestCase):
pass

View File

@@ -6,8 +6,9 @@ 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 frappe.utils import time_diff, now_datetime, get_datetime, flt
from typing import Optional
from lms.lms.utils import get_average_rating, get_lesson_count
@frappe.whitelist()
@@ -289,11 +290,16 @@ def get_file_info(file_url):
@frappe.whitelist(allow_guest=True)
def get_branding():
"""Get branding details."""
return {
"brand_name": frappe.db.get_single_value("Website Settings", "app_name"),
"brand_html": frappe.db.get_single_value("Website Settings", "brand_html"),
"favicon": frappe.db.get_single_value("Website Settings", "favicon"),
}
website_settings = frappe.get_single("Website Settings")
image_fields = ["banner_image", "footer_logo", "favicon"]
for field in image_fields:
if website_settings.get(field):
website_settings.update({field: get_file_info(website_settings.get(field))})
else:
website_settings.update({field: None})
return website_settings
@frappe.whitelist()
@@ -320,7 +326,7 @@ def get_evaluator_details(evaluator):
)
if frappe.db.exists("Course Evaluator", {"evaluator": evaluator}):
doc = frappe.get_doc("Course Evaluator", evaluator, as_dict=1)
doc = frappe.get_doc("Course Evaluator", evaluator)
else:
doc = frappe.new_doc("Course Evaluator")
doc.evaluator = evaluator
@@ -574,14 +580,17 @@ def get_members(start=0, search=""):
"""
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
or_filters = {}
if search:
filters["full_name"] = ["like", f"%{search}%"]
or_filters["full_name"] = ["like", f"%{search}%"]
or_filters["email"] = ["like", f"%{search}%"]
members = frappe.get_all(
"User",
filters=filters,
fields=["name", "full_name", "user_image", "username", "last_active"],
or_filters=or_filters,
page_length=20,
start=start,
)
@@ -706,3 +715,90 @@ def delete_documents(doctype, documents):
frappe.only_for("Moderator")
for doc in documents:
frappe.delete_doc(doctype, doc)
@frappe.whitelist()
def get_payment_gateway_details(payment_gateway):
fields = []
gateway = frappe.get_doc("Payment Gateway", payment_gateway)
if gateway.gateway_controller is None:
try:
data = frappe.get_doc(f"{payment_gateway} Settings").as_dict()
meta = frappe.get_meta(f"{payment_gateway} Settings").fields
doctype = f"{payment_gateway} Settings"
docname = f"{payment_gateway} Settings"
except Exception:
frappe.throw(_("{0} Settings not found").format(payment_gateway))
else:
try:
data = frappe.get_doc(gateway.gateway_settings, gateway.gateway_controller).as_dict()
meta = frappe.get_meta(gateway.gateway_settings).fields
doctype = gateway.gateway_settings
docname = gateway.gateway_controller
except Exception:
frappe.throw(_("{0} Settings not found").format(payment_gateway))
for row in meta:
if row.fieldtype not in ["Column Break", "Section Break"]:
if row.fieldtype in ["Attach", "Attach Image"]:
fieldtype = "Upload"
data[row.fieldname] = get_file_info(data.get(row.fieldname))
else:
fieldtype = row.fieldtype
fields.append(
{
"label": row.label,
"name": row.fieldname,
"type": fieldtype,
}
)
return {
"fields": fields,
"data": data,
"doctype": doctype,
"docname": docname,
}
def update_course_statistics():
courses = frappe.get_all("LMS Course", fields=["name"])
for course in courses:
lessons = get_lesson_count(course.name)
enrollments = frappe.db.count(
"LMS Enrollment", {"course": course.name, "member_type": "Student"}
)
avg_rating = get_average_rating(course.name) or 0
avg_rating = flt(avg_rating, frappe.get_system_settings("float_precision") or 3)
frappe.db.set_value(
"LMS Course",
course.name,
{"lessons": lessons, "enrollments": enrollments, "rating": avg_rating},
)
@frappe.whitelist()
def get_announcements(batch):
return frappe.get_all(
"Communication",
filters={
"reference_doctype": "LMS Batch",
"reference_name": batch,
},
fields=[
"subject",
"content",
"recipients",
"cc",
"communication_date",
"sender",
"sender_full_name",
],
order_by="communication_date desc",
)

View File

@@ -7,17 +7,3 @@ from frappe.model.document import Document
class BatchStudent(Document):
pass
@frappe.whitelist()
def enroll_batch(batch_name):
if frappe.db.exists(
"Batch Student", {"student": frappe.session.user, "parent": batch_name}
):
frappe.throw("You are already enrolled in this batch")
enrollment = frappe.new_doc("Batch Student")
enrollment.student = frappe.session.user
enrollment.parent = batch_name
enrollment.parentfield = "students"
enrollment.parenttype = "LMS Batch"
enrollment.save(ignore_permissions=True)

View File

@@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestBatchStudent(FrappeTestCase):
class TestBatchStudent(UnitTestCase):
pass

View File

@@ -9,9 +9,8 @@
"engine": "InnoDB",
"field_order": [
"course",
"title",
"column_break_3",
"description",
"title",
"section_break_5",
"lessons"
],
@@ -35,11 +34,6 @@
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
{
"fieldname": "description",
"fieldtype": "Small Text",
"label": "Description"
},
{
"fieldname": "section_break_5",
"fieldtype": "Section Break"
@@ -59,7 +53,7 @@
"link_fieldname": "chapter"
}
],
"modified": "2023-09-29 17:03:58.013819",
"modified": "2024-10-29 16:54:20.904683",
"modified_by": "Administrator",
"module": "LMS",
"name": "Course Chapter",

View File

@@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCourseEvaluator(FrappeTestCase):
class TestCourseEvaluator(UnitTestCase):
pass

View File

@@ -1,148 +1,4 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on("Course Lesson", {
setup: function (frm) {
frm.trigger("setup_help");
},
setup_help(frm) {
let quiz_link = `<a href="/app/lms-quiz"> ${__("Quiz List")} </a>`;
let exercise_link = `<a href="/app/lms-exercise"> ${__(
"Exercise List"
)} </a>`;
let file_link = `<a href="/app/file"> ${__("File DocType")} </a>`;
frm.get_field("help").html(`
<p>${__(
"You can add some more additional content to the lesson using a special syntax. The table below mentions all types of dynamic content that you can add to the lessons and the syntax for the same."
)}</p>
<table class="table">
<tr style="background-color: var(--fg-hover-color); font-weight: bold">
<th style="width: 20%;">
${__("Content Type")}
</th>
<th style="width: 40%;">
${__("Syntax")}
</th>
<th>
${__("Description")}
</th>
</tr>
<tr>
<td>
${__("YouTube Video")}
</td>
<td>
{{ YouTubeVideo("unique_embed_id") }}
</td>
<td>
<span>
${__(
"Copy and paste the syntax in the editor. Replace 'embed_src' with the embed source that YouTube provides. To get the source, follow the steps mentioned below."
)}
</span>
<ul class="p-4">
<li>
${__("Upload the video on youtube.")}
</li>
<li>
${__(
"When you share a youtube video, it shows an option called Embed."
)}
</li>
<li>
${__(
"On clicking it, it provides an iframe. Copy the source (src) of the iframe and paste it here."
)}
</li>
</ul>
</td>
</tr>
<tr>
<td>
${__("Quiz")}
</td>
<td>
{{ Quiz("lms_quiz_id") }}
</td>
<td>
${__(
"Copy and paste the syntax in the editor. Replace 'lms_quiz_id' with the ID of the Quiz you want to add. You can get the ID of the quiz from the {0}.",
[quiz_link]
)}
</td>
</tr>
<tr>
<td>
${__("Video")}
</td>
<td>
{{ Video("url_of_source") }}
</td>
<td>
${__(
"Upload a video from your local machine to the {0}. Copy and paste this syntax in the editor. Replace 'url_of_source' with the File URL field of the document you created in the File DocType.",
[file_link]
)}
</td>
</tr>
<tr>
<td>
${"Exercise"}
</td>
<td>
{{ Exercise("exercise_id") }}
</td>
<td>
${__(
"Copy and paste the syntax in the editor. Replace 'exercise_id' with the ID of the Exercise you want to add. You can get the ID of the exercise from the {0}.",
[exercise_link]
)}
</td>
</tr>
<tr>
<td>
${__("Assignment")}
</td>
<td>
{{ Assignment("id-filetype") }}
</td>
</tr>
</table>
<hr>
<table class="table">
<tr style="background-color: var(--fg-hover-color); font-weight: bold">
<th style="width: 90%">
${__("Supported File Types for Assignment")}
</th>
<th>
${__("Syntax")}
</th>
</tr>
<tr>
<td>
.doc, .docx, .xml
<td>
${__("Document")}
</td>
</tr>
<tr>
<td>
.pdf
</td>
<td>
${__("PDF")}
</td>
</tr>
<tr>
<td>
.png, .jpg, .jpeg
</td>
<td>
${__("Image")}
</td>
</tr>
</table>
`);
},
});
frappe.ui.form.on("Course Lesson", {});

View File

@@ -55,6 +55,7 @@
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Title",
"reqd": 1
},
@@ -161,7 +162,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-04-03 10:48:17.525859",
"modified": "2024-10-08 11:04:54.748773",
"modified_by": "Administrator",
"module": "LMS",
"name": "Course Lesson",

View File

@@ -126,7 +126,7 @@ def save_progress(lesson, course):
def capture_progress_for_analytics(progress, course):
if progress in [25, 50, 75, 100]:
capture("course_progress", "lms", {"course": course, "progress": progress})
capture("course_progress", "lms", properties={"course": course, "progress": progress})
def get_quiz_progress(lesson):

View File

@@ -15,20 +15,22 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Assessment Type",
"options": "DocType"
"options": "DocType",
"reqd": 1
},
{
"fieldname": "assessment_name",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Assessment Name",
"options": "assessment_type"
"options": "assessment_type",
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-05-29 14:56:36.602399",
"modified": "2024-10-11 19:16:01.630524",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Assessment",

View File

@@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestLMSAssignment(FrappeTestCase):
class TestLMSAssignment(UnitTestCase):
pass

View File

@@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestLMSBadge(FrappeTestCase):
class TestLMSBadge(UnitTestCase):
pass

View File

@@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestLMSBadgeAssignment(FrappeTestCase):
class TestLMSBadgeAssignment(UnitTestCase):
pass

View File

@@ -15,6 +15,7 @@ from lms.lms.utils import (
get_lesson_url,
get_quiz_details,
get_assignment_details,
update_payment_record,
)
from frappe.email.doctype.email_template.email_template import get_email_template
@@ -26,11 +27,13 @@ class LMSBatch(Document):
self.validate_batch_end_date()
self.validate_duplicate_courses()
self.validate_duplicate_students()
self.validate_payments_app()
self.validate_duplicate_assessments()
self.validate_membership()
self.validate_timetable()
self.send_confirmation_mail()
self.validate_evaluation_end_date()
self.add_students_to_live_class()
def validate_batch_end_date(self):
if self.end_date < self.start_date:
@@ -55,6 +58,12 @@ class LMSBatch(Document):
_("Course {0} has already been added to this batch.").format(frappe.bold(title))
)
def validate_payments_app(self):
if self.paid_batch:
installed_apps = frappe.get_installed_apps()
if "payments" not in installed_apps:
frappe.throw(_("Please install the Payments app to create a paid batches."))
def validate_duplicate_assessments(self):
assessments = [row.assessment_name for row in self.assessment]
for assessment in self.assessment:
@@ -131,6 +140,27 @@ class LMSBatch(Document):
if cint(self.seat_count) < len(self.students):
frappe.throw(_("There are no seats available in this batch."))
def add_students_to_live_class(self):
for student in self.students:
if student.is_new():
live_classes = frappe.get_all(
"LMS Live Class", {"batch_name": self.name}, ["name", "event"]
)
for live_class in live_classes:
if live_class.event:
frappe.get_doc(
{
"doctype": "Event Participants",
"reference_doctype": "User",
"reference_docname": student.student,
"email": student.student,
"parent": live_class.event,
"parenttype": "Event",
"parentfield": "event_participants",
}
).save()
def validate_timetable(self):
for schedule in self.timetable:
if schedule.start_time and schedule.end_time:
@@ -164,23 +194,9 @@ class LMSBatch(Document):
_("Row #{0} Date cannot be outside the batch duration.").format(schedule.idx)
)
@frappe.whitelist()
def remove_student(student, batch_name):
frappe.only_for("Moderator")
frappe.db.delete("Batch Student", {"student": student, "parent": batch_name})
@frappe.whitelist()
def remove_course(course, parent):
frappe.only_for("Moderator")
frappe.db.delete("Batch Course", {"course": course, "parent": parent})
@frappe.whitelist()
def remove_assessment(assessment, parent):
frappe.only_for("Moderator")
frappe.db.delete("LMS Assessment", {"assessment_name": assessment, "parent": parent})
def on_payment_authorized(self, payment_status):
if payment_status in ["Authorized", "Completed"]:
update_payment_record("LMS Batch", self.name)
@frappe.whitelist()

View File

@@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestLMSClass(FrappeTestCase):
class TestLMSBatch(UnitTestCase):
pass

View File

@@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
import unittest
class TestLMSBatchTimetable(FrappeTestCase):
class TestLMSBatchTimetable(unittest.TestCase):
pass

View File

@@ -15,12 +15,13 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Category",
"reqd": 1,
"unique": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-06-15 15:14:11.341961",
"modified": "2024-09-23 19:33:49.593950",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Category",
@@ -55,5 +56,6 @@
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "category"
"title_field": "category",
"track_changes": 1
}

View File

@@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestLMSCategory(FrappeTestCase):
class TestLMSCategory(UnitTestCase):
pass

View File

@@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestLMSCertificateEvaluation(FrappeTestCase):
class TestLMSCertificateEvaluation(UnitTestCase):
pass

View File

@@ -13,6 +13,7 @@ from frappe.utils import (
get_datetime,
nowtime,
get_time,
get_fullname,
)
from lms.lms.utils import get_evaluator
import json
@@ -32,6 +33,7 @@ class LMSCertificateRequest(Document):
def set_evaluator(self):
if not self.evaluator:
self.evaluator = get_evaluator(self.course, self.batch_name)
self.evaluator_name = get_fullname(self.evaluator)
def validate_unavailability(self):
if self.evaluator:

View File

@@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestLMSCertificateRequest(FrappeTestCase):
class TestLMSCertificateRequest(UnitTestCase):
pass

View File

@@ -16,10 +16,12 @@
"field_order": [
"title",
"video_link",
"image",
"column_break_3",
"instructors",
"tags",
"column_break_htgn",
"image",
"category",
"status",
"section_break_7",
"published",
@@ -46,7 +48,12 @@
"certification_section",
"enable_certification",
"column_break_rxww",
"expiry"
"expiry",
"tab_4_tab",
"statistics_section",
"enrollments",
"lessons",
"rating"
],
"fields": [
{
@@ -237,6 +244,46 @@
"fieldname": "certification_tab",
"fieldtype": "Tab Break",
"label": "Certification"
},
{
"fieldname": "column_break_htgn",
"fieldtype": "Column Break"
},
{
"fieldname": "category",
"fieldtype": "Link",
"label": "Category",
"options": "LMS Category"
},
{
"fieldname": "tab_4_tab",
"fieldtype": "Tab Break",
"label": "Statistics"
},
{
"fieldname": "statistics_section",
"fieldtype": "Section Break"
},
{
"default": "0",
"fieldname": "enrollments",
"fieldtype": "Data",
"label": "Enrollments",
"read_only": 1
},
{
"default": "0",
"fieldname": "lessons",
"fieldtype": "Data",
"label": "Lessons",
"read_only": 1
},
{
"default": "0",
"fieldname": "rating",
"fieldtype": "Data",
"label": "Rating",
"read_only": 1
}
],
"is_published_field": "published",
@@ -263,7 +310,7 @@
}
],
"make_attachments_public": 1,
"modified": "2024-07-12 13:54:40.474097",
"modified": "2024-10-30 23:08:31.842860",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Course",

View File

@@ -8,7 +8,7 @@ from frappe.model.document import Document
from frappe.utils import cint, today
from frappe.utils.telemetry import capture
from lms.lms.utils import get_chapters, can_create_courses
from ...utils import generate_slug, validate_image
from ...utils import generate_slug, validate_image, update_payment_record
from frappe import _
@@ -18,6 +18,7 @@ class LMSCourse(Document):
self.validate_instructors()
self.validate_video_link()
self.validate_status()
self.validate_payments_app()
self.image = validate_image(self.image)
def validate_published(self):
@@ -44,10 +45,20 @@ class LMSCourse(Document):
if self.published:
self.status = "Approved"
def validate_payments_app(self):
if self.paid_course:
installed_apps = frappe.get_installed_apps()
if "payments" not in installed_apps:
frappe.throw(_("Please install the Payments app to create a paid courses."))
def on_update(self):
if not self.upcoming and self.has_value_changed("upcoming"):
self.send_email_to_interested_users()
def on_payment_authorized(self, payment_status):
if payment_status in ["Authorized", "Completed"]:
update_payment_record("LMS Course", self.name)
def send_email_to_interested_users(self):
interested_users = frappe.get_all(
"LMS Course Interest", {"course": self.name}, ["name", "user"]
@@ -176,192 +187,3 @@ def reindex_exercises(doc):
course = frappe.get_doc("LMS Course", course_data["name"])
course.reindex_exercises()
frappe.msgprint("All exercises in this course have been re-indexed.")
@frappe.whitelist(allow_guest=True)
def search_course(text):
courses = frappe.get_all(
"LMS Course",
filters={"published": True},
or_filters={
"title": ["like", f"%{text}%"],
"tags": ["like", f"%{text}%"],
"short_introduction": ["like", f"%{text}%"],
"description": ["like", f"%{text}%"],
},
fields=["name", "title"],
)
return courses
@frappe.whitelist()
def submit_for_review(course):
chapters = frappe.get_all("Chapter Reference", {"parent": course})
if not len(chapters):
return "No Chp"
frappe.db.set_value("LMS Course", course, "status", "Under Review")
return "OK"
@frappe.whitelist()
def save_course(
tags,
title,
short_introduction,
video_link,
description,
course,
published,
upcoming,
image=None,
paid_course=False,
course_price=None,
currency=None,
):
if not can_create_courses(course):
return
if course:
doc = frappe.get_doc("LMS Course", course)
else:
doc = frappe.get_doc({"doctype": "LMS Course"})
doc.update(
{
"title": title,
"short_introduction": short_introduction,
"video_link": video_link,
"image": image,
"description": description,
"tags": tags,
"published": cint(published),
"upcoming": cint(upcoming),
"paid_course": cint(paid_course),
"course_price": course_price,
"currency": currency,
}
)
doc.save(ignore_permissions=True)
return doc.name
@frappe.whitelist()
def save_chapter(course, title, chapter_description, idx, chapter):
if chapter:
doc = frappe.get_doc("Course Chapter", chapter)
else:
doc = frappe.get_doc({"doctype": "Course Chapter"})
doc.update({"course": course, "title": title, "description": chapter_description})
doc.save(ignore_permissions=True)
if chapter:
chapter_reference = frappe.get_doc("Chapter Reference", {"chapter": chapter})
else:
chapter_reference = frappe.get_doc(
{
"doctype": "Chapter Reference",
"parent": course,
"parenttype": "LMS Course",
"parentfield": "chapters",
"idx": idx,
}
)
chapter_reference.update({"chapter": doc.name})
chapter_reference.save(ignore_permissions=True)
return doc.name
@frappe.whitelist()
def save_lesson(
title,
body,
chapter,
preview,
idx,
lesson,
instructor_notes=None,
youtube=None,
quiz_id=None,
question=None,
file_type=None,
):
if lesson:
doc = frappe.get_doc("Course Lesson", lesson)
else:
doc = frappe.get_doc({"doctype": "Course Lesson"})
doc.update(
{
"chapter": chapter,
"title": title,
"body": body,
"instructor_notes": instructor_notes,
"include_in_preview": preview,
"youtube": youtube,
"quiz_id": quiz_id,
"question": question,
"file_type": file_type,
}
)
doc.save(ignore_permissions=True)
if lesson:
lesson_reference = frappe.get_doc("Lesson Reference", {"lesson": lesson})
else:
lesson_reference = frappe.get_doc(
{
"doctype": "Lesson Reference",
"parent": chapter,
"parenttype": "Course Chapter",
"parentfield": "lessons",
"idx": idx,
}
)
lesson_reference.update({"lesson": doc.name})
lesson_reference.save(ignore_permissions=True)
return doc.name
@frappe.whitelist()
def reorder_lesson(old_chapter, old_lesson_array, new_chapter, new_lesson_array):
if old_chapter == new_chapter:
sort_lessons(new_chapter, new_lesson_array)
else:
sort_lessons(old_chapter, old_lesson_array)
sort_lessons(new_chapter, new_lesson_array)
def sort_lessons(chapter, lesson_array):
lesson_array = json.loads(lesson_array)
for les in lesson_array:
ref = frappe.get_all("Lesson Reference", {"lesson": les}, ["name", "idx"])
if ref:
frappe.db.set_value(
"Lesson Reference",
ref[0].name,
{
"parent": chapter,
"idx": lesson_array.index(les) + 1,
},
)
@frappe.whitelist()
def reorder_chapter(chapter_array):
chapter_array = json.loads(chapter_array)
for chap in chapter_array:
ref = frappe.get_all("Chapter Reference", {"chapter": chap}, ["name", "idx"])
if ref:
frappe.db.set_value(
"Chapter Reference",
ref[0].name,
{
"idx": chapter_array.index(chap) + 1,
},
)

View File

@@ -75,7 +75,8 @@
"in_standard_filter": 1,
"label": "Course",
"options": "LMS Course",
"reqd": 1
"reqd": 1,
"search_index": 1
},
{
"fieldname": "current_lesson",
@@ -126,7 +127,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-05-14 14:50:08.405033",
"modified": "2024-10-30 12:44:16.103598",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Enrollment",

View File

@@ -10,19 +10,20 @@
"title",
"host",
"batch_name",
"event",
"column_break_astv",
"date",
"time",
"duration",
"section_break_glxh",
"description",
"section_break_glxh",
"date",
"duration",
"column_break_spvt",
"time",
"timezone",
"password",
"auto_recording",
"section_break_yrpq",
"password",
"start_url",
"column_break_yokr",
"auto_recording",
"join_url"
],
"fields": [
@@ -122,11 +123,18 @@
"fieldtype": "Select",
"label": "Auto Recording",
"options": "No Recording\nLocal\nCloud"
},
{
"fieldname": "event",
"fieldtype": "Link",
"label": "Event",
"options": "Event",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-01-09 11:22:33.272341",
"modified": "2024-10-31 15:41:35.540856",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Live Class",

View File

@@ -16,6 +16,7 @@ class LMSLiveClass(Document):
if calendar:
event = self.create_event()
self.add_event_participants(event, calendar)
frappe.db.set_value(self.doctype, self.name, "event", event.name)
def create_event(self):
start = f"{self.date} {self.time}"

View File

@@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestLMSLiveClass(FrappeTestCase):
class TestLMSLiveClass(UnitTestCase):
pass

View File

@@ -76,6 +76,7 @@
"default": "0",
"fieldname": "payment_received",
"fieldtype": "Check",
"in_standard_filter": 1,
"label": "Payment Received"
},
{
@@ -140,7 +141,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-10-26 16:54:12.408274",
"modified": "2024-10-31 15:33:39.420366",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Payment",

View File

@@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestLMSPayment(FrappeTestCase):
class TestLMSPayment(UnitTestCase):
pass

View File

@@ -51,7 +51,7 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "Choices\nUser Input"
"options": "Choices\nUser Input\nOpen Ended"
},
{
"depends_on": "eval:doc.type == \"Choices\";",
@@ -196,7 +196,7 @@
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2024-08-01 12:53:22.540990",
"modified": "2024-10-07 09:41:17.862774",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Question",

View File

@@ -17,7 +17,7 @@ def validate_correct_answers(question):
if question.type == "Choices":
validate_duplicate_options(question)
validate_correct_options(question)
else:
elif question.type == "User Input":
validate_possible_answer(question)

View File

@@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestLMSQuestion(FrappeTestCase):
class TestLMSQuestion(UnitTestCase):
pass

View File

@@ -10,10 +10,11 @@
"title",
"max_attempts",
"show_answers",
"show_submission_history",
"column_break_gaac",
"total_marks",
"passing_percentage",
"show_submission_history",
"duration",
"section_break_tzbu",
"shuffle_questions",
"column_break_clsh",
@@ -128,11 +129,16 @@
{
"fieldname": "column_break_clsh",
"fieldtype": "Column Break"
},
{
"fieldname": "duration",
"fieldtype": "Duration",
"label": "Duration"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-08-09 12:21:36.256522",
"modified": "2024-10-11 22:39:40.381183",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Quiz",

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