Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b3a71e424 | ||
|
|
5499e7294d | ||
|
|
fe1f78f8aa | ||
|
|
1709c6b658 | ||
|
|
d3583a2cfb | ||
|
|
634035fbc0 | ||
|
|
3c5b18411b | ||
|
|
82bb45a9ef | ||
|
|
373f3df196 | ||
|
|
6021f15bac | ||
|
|
8f6f35d7c1 | ||
|
|
7aa5f4d20b | ||
|
|
64b54b05a6 | ||
|
|
22b1f22df4 | ||
|
|
ae4e5539d7 | ||
|
|
dbd96329b5 | ||
|
|
c118ec7c4a | ||
|
|
7aab449502 | ||
|
|
cf166b3a57 | ||
|
|
da5910d40d | ||
|
|
8640ecf9be | ||
|
|
c4faceff30 | ||
|
|
01bd017bda | ||
|
|
d76357981b | ||
|
|
19b759e9fb | ||
|
|
df3bca6405 | ||
|
|
5cde79b5eb | ||
|
|
9b35cdbddc | ||
|
|
70ec22004a | ||
|
|
95ed77421a | ||
|
|
d64ec9817c | ||
|
|
ce01b7634f | ||
|
|
e0819f83bc | ||
|
|
f87d28c2f5 | ||
|
|
544b59744b | ||
|
|
467dfb831d | ||
|
|
4c4b4eaf55 | ||
|
|
227e5d00e5 | ||
|
|
73e9e384c8 | ||
|
|
5bebdcba68 | ||
|
|
1c2e52ae4b | ||
|
|
9377e89561 | ||
|
|
4cae05ecbe | ||
|
|
909dcfd51e | ||
|
|
2bd96a1f2a | ||
|
|
aca41080ee | ||
|
|
1c351696a9 | ||
|
|
51a8958aa6 | ||
|
|
777b8aed02 | ||
|
|
3672b90075 | ||
|
|
92c7e613db | ||
|
|
5c58b85a00 | ||
|
|
8af82daa37 | ||
|
|
224bb18d3e | ||
|
|
aab7bdcc20 | ||
|
|
c5ca428d98 | ||
|
|
af0cc7126b | ||
|
|
a085050d27 | ||
|
|
2442f35f56 | ||
|
|
ed79ea536b | ||
|
|
b3d0aecd14 | ||
|
|
5f43e67c0b | ||
|
|
49a765a9a6 | ||
|
|
4d82bc86e8 | ||
|
|
8fe02b83b8 | ||
|
|
9c9075606b | ||
|
|
53285a0d19 | ||
|
|
9cdeaebb47 | ||
|
|
a9cb52c68b | ||
|
|
f33e950e83 | ||
|
|
9c9b5963fe | ||
|
|
1597054cc9 | ||
|
|
deba6aa845 | ||
|
|
2d8ba3b84e | ||
|
|
e56b28abad | ||
|
|
eb350c5a20 | ||
|
|
961d5ec77b | ||
|
|
fa566514aa | ||
|
|
6e97449bf7 | ||
|
|
016dafb3c3 | ||
|
|
675bcc8956 | ||
|
|
aba4c034fc | ||
|
|
c76d8c582f | ||
|
|
f1cb0e6f3c | ||
|
|
d296687456 | ||
|
|
5b68001c94 | ||
|
|
736d79b8c9 | ||
|
|
98c0bd5f3e | ||
|
|
8b1d9bb5a9 | ||
|
|
289a0f9122 | ||
|
|
3cd08c80c8 | ||
|
|
3d82c36250 | ||
|
|
9b9af0215a | ||
|
|
2e4cf02737 | ||
|
|
438e9e1c47 | ||
|
|
36ded70eef | ||
|
|
ba78a15a1f | ||
|
|
93061194bb | ||
|
|
6d41e4e552 | ||
|
|
3b06968d0a | ||
|
|
fc81f1aa26 | ||
|
|
59d8848125 | ||
|
|
a067695f71 | ||
|
|
be870e8145 | ||
|
|
8a17dca351 | ||
|
|
1c9f636ad1 | ||
|
|
008cc66cdd | ||
|
|
b6bf9c0032 | ||
|
|
d295898674 | ||
|
|
4fdca4691a | ||
|
|
7c055af496 | ||
|
|
60a3da283e | ||
|
|
576258ec6e | ||
|
|
01120fbc48 | ||
|
|
ad07f883b5 | ||
|
|
bb9b179e05 | ||
|
|
11a9bff57d | ||
|
|
e18f0c9dad | ||
|
|
41ad3d00de | ||
|
|
33c76e842f | ||
|
|
35a7cce283 | ||
|
|
e0f569c382 | ||
|
|
d8ab88be28 | ||
|
|
cef4b70182 |
@@ -13,6 +13,6 @@ module.exports = defineConfig({
|
|||||||
openMode: 0,
|
openMode: 0,
|
||||||
},
|
},
|
||||||
e2e: {
|
e2e: {
|
||||||
baseUrl: "http://test_site_ui:8000",
|
baseUrl: "http://test:8000",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ describe("Course Creation", () => {
|
|||||||
cy.visit("/lms/courses");
|
cy.visit("/lms/courses");
|
||||||
|
|
||||||
// Create a course
|
// Create a course
|
||||||
cy.get("a").contains("New Course").click();
|
cy.get("header").children().last().children().last().click();
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.url().should("include", "/courses/new/edit");
|
cy.url().should("include", "/courses/new/edit");
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ describe("Course Creation", () => {
|
|||||||
.should("be.visible")
|
.should("be.visible")
|
||||||
.within(() => {
|
.within(() => {
|
||||||
cy.get("label").contains("Title").type("Test Chapter");
|
cy.get("label").contains("Title").type("Test Chapter");
|
||||||
cy.button("Add Chapter").click();
|
cy.button("Create").click();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add Lesson
|
// Add Lesson
|
||||||
|
|||||||
@@ -18,11 +18,12 @@
|
|||||||
"@editorjs/nested-list": "^1.4.2",
|
"@editorjs/nested-list": "^1.4.2",
|
||||||
"@editorjs/paragraph": "^2.11.3",
|
"@editorjs/paragraph": "^2.11.3",
|
||||||
"@editorjs/simple-image": "^1.6.0",
|
"@editorjs/simple-image": "^1.6.0",
|
||||||
|
"ace-builds": "^1.36.2",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"codemirror-editor-vue3": "^2.8.0",
|
"codemirror-editor-vue3": "^2.8.0",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"frappe-ui": "^0.1.69",
|
"frappe-ui": "^0.1.72",
|
||||||
"lucide-vue-next": "^0.383.0",
|
"lucide-vue-next": "^0.383.0",
|
||||||
"markdown-it": "^14.0.0",
|
"markdown-it": "^14.0.0",
|
||||||
"pinia": "^2.0.33",
|
"pinia": "^2.0.33",
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import DesktopLayout from './components/DesktopLayout.vue'
|
|||||||
import MobileLayout from './components/MobileLayout.vue'
|
import MobileLayout from './components/MobileLayout.vue'
|
||||||
import { stopSession } from '@/telemetry'
|
import { stopSession } from '@/telemetry'
|
||||||
import { init as initTelemetry } from '@/telemetry'
|
import { init as initTelemetry } from '@/telemetry'
|
||||||
|
import { usersStore } from '@/stores/user'
|
||||||
|
|
||||||
const screenSize = useScreenSize()
|
const screenSize = useScreenSize()
|
||||||
|
let { userResource } = usersStore()
|
||||||
|
|
||||||
const Layout = computed(() => {
|
const Layout = computed(() => {
|
||||||
if (screenSize.width < 640) {
|
if (screenSize.width < 640) {
|
||||||
@@ -26,6 +28,7 @@ const Layout = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (!userResource.data) return
|
||||||
await initTelemetry()
|
await initTelemetry()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createListResource, Avatar } from 'frappe-ui'
|
import { createResource, Avatar } from 'frappe-ui'
|
||||||
import { timeAgo } from '@/utils'
|
import { timeAgo } from '@/utils'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -35,24 +35,15 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const communications = createListResource({
|
const communications = createResource({
|
||||||
doctype: 'Communication',
|
url: 'lms.lms.api.get_announcements',
|
||||||
fields: [
|
makeParams(value) {
|
||||||
'subject',
|
return {
|
||||||
'content',
|
batch: props.batch,
|
||||||
'recipients',
|
}
|
||||||
'cc',
|
|
||||||
'communication_date',
|
|
||||||
'sender',
|
|
||||||
'sender_full_name',
|
|
||||||
],
|
|
||||||
filters: {
|
|
||||||
reference_doctype: 'LMS Batch',
|
|
||||||
reference_name: props.batch,
|
|
||||||
},
|
},
|
||||||
orderBy: 'communication_date desc',
|
|
||||||
auto: true,
|
auto: true,
|
||||||
cache: ['batch', props.batch],
|
cache: ['announcement', props.batch],
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ const unreadCount = ref(0)
|
|||||||
const sidebarLinks = ref(getSidebarLinks())
|
const sidebarLinks = ref(getSidebarLinks())
|
||||||
const showPageModal = ref(false)
|
const showPageModal = ref(false)
|
||||||
const isModerator = ref(false)
|
const isModerator = ref(false)
|
||||||
|
const isInstructor = ref(false)
|
||||||
const pageToEdit = ref(null)
|
const pageToEdit = ref(null)
|
||||||
const showWebPages = ref(false)
|
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) => {
|
const openPageModal = (link) => {
|
||||||
showPageModal.value = true
|
showPageModal.value = true
|
||||||
pageToEdit.value = link
|
pageToEdit.value = link
|
||||||
@@ -197,6 +209,8 @@ const getSidebarFromStorage = () => {
|
|||||||
watch(userResource, () => {
|
watch(userResource, () => {
|
||||||
if (userResource.data) {
|
if (userResource.data) {
|
||||||
isModerator.value = userResource.data.is_moderator
|
isModerator.value = userResource.data.is_moderator
|
||||||
|
isInstructor.value = userResource.data.is_instructor
|
||||||
|
addQuizzes()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ const getRowRoute = (row) => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
name: 'Quiz',
|
name: 'QuizPage',
|
||||||
params: {
|
params: {
|
||||||
quizID: row.assessment_name,
|
quizID: row.assessment_name,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ const props = defineProps({
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
audio.value = document.querySelector('audio')
|
audio.value = document.querySelector('audio')
|
||||||
console.log(audio.value)
|
|
||||||
audio.value.onloadedmetadata = () => {
|
audio.value.onloadedmetadata = () => {
|
||||||
duration.value = audio.value.duration
|
duration.value = audio.value.duration
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
variant="solid"
|
variant="solid"
|
||||||
class="w-full mt-2"
|
class="w-full mt-2"
|
||||||
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
|
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
|
||||||
|
@click="enrollInBatch()"
|
||||||
>
|
>
|
||||||
{{ __('Enroll Now') }}
|
{{ __('Enroll Now') }}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -97,11 +98,13 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { inject, computed } from 'vue'
|
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 { 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 DateRange from '@/components/Common/DateRange.vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
|
||||||
const props = defineProps({
|
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(() => {
|
const seats_left = computed(() => {
|
||||||
if (props.batch.data?.seat_count) {
|
if (props.batch.data?.seat_count) {
|
||||||
return props.batch.data?.seat_count - props.batch.data?.students?.length
|
return props.batch.data?.seat_count - props.batch.data?.students?.length
|
||||||
|
|||||||
92
frontend/src/components/BrandSettings.vue
Normal file
92
frontend/src/components/BrandSettings.vue
Normal 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>
|
||||||
204
frontend/src/components/Controls/CodeEditor.vue
Normal file
204
frontend/src/components/Controls/CodeEditor.vue
Normal 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>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="block mb-1" :class="labelClasses" v-if="label">
|
<label class="block mb-1" :class="labelClasses" v-if="label">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
|
<span class="text-red-500" v-if="required">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="grid grid-cols-3 gap-1">
|
<div class="grid grid-cols-3 gap-1">
|
||||||
<Button
|
<Button
|
||||||
@@ -115,6 +116,9 @@ const props = defineProps({
|
|||||||
type: Function,
|
type: Function,
|
||||||
default: (value) => `${value} is an Invalid value`,
|
default: (value) => `${value} is an Invalid value`,
|
||||||
},
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const values = defineModel()
|
const values = defineModel()
|
||||||
@@ -152,24 +156,11 @@ const filterOptions = createResource({
|
|||||||
url: 'frappe.desk.search.search_link',
|
url: 'frappe.desk.search.search_link',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
cache: [text.value, props.doctype],
|
cache: [text.value, props.doctype],
|
||||||
|
auto: true,
|
||||||
params: {
|
params: {
|
||||||
txt: text.value,
|
txt: text.value,
|
||||||
doctype: props.doctype,
|
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(() => {
|
const options = computed(() => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Star } from 'lucide-vue-next'
|
import { Star } from 'lucide-vue-next'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
|
|||||||
@@ -30,29 +30,29 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col flex-auto p-4">
|
<div class="flex flex-col flex-auto p-4">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div v-if="course.lesson_count">
|
<div v-if="course.lessons">
|
||||||
<Tooltip :text="__('Lessons')">
|
<Tooltip :text="__('Lessons')">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||||
{{ course.lesson_count }}
|
{{ course.lessons }}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="course.enrollment_count">
|
<div v-if="course.enrollments">
|
||||||
<Tooltip :text="__('Enrolled Students')">
|
<Tooltip :text="__('Enrolled Students')">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
<Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||||
{{ course.enrollment_count }}
|
{{ course.enrollments }}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="course.avg_rating">
|
<div v-if="course.rating">
|
||||||
<Tooltip :text="__('Average Rating')">
|
<Tooltip :text="__('Average Rating')">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
<Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||||
{{ course.avg_rating }}
|
{{ course.rating }}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -93,21 +93,19 @@
|
|||||||
<div class="flex items-center mb-3">
|
<div class="flex items-center mb-3">
|
||||||
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
|
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
|
||||||
<span class="ml-2">
|
<span class="ml-2">
|
||||||
{{ course.data.lesson_count }} {{ __('Lessons') }}
|
{{ course.data.lessons }} {{ __('Lessons') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center mb-3">
|
<div class="flex items-center mb-3">
|
||||||
<Users class="h-5 w-5 stroke-1.5 text-gray-600" />
|
<Users class="h-5 w-5 stroke-1.5 text-gray-600" />
|
||||||
<span class="ml-2">
|
<span class="ml-2">
|
||||||
{{ course.data.enrollment_count_formatted }}
|
{{ formatAmount(course.data.enrollments) }}
|
||||||
{{ __('Enrolled Students') }}
|
{{ __('Enrolled Students') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
|
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
|
||||||
<span class="ml-2">
|
<span class="ml-2"> {{ course.data.rating }} {{ __('Rating') }} </span>
|
||||||
{{ course.data.avg_rating }} {{ __('Rating') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +114,7 @@
|
|||||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||||
import { computed, inject } from 'vue'
|
import { computed, inject } from 'vue'
|
||||||
import { Button, createResource } from 'frappe-ui'
|
import { Button, createResource } from 'frappe-ui'
|
||||||
import { createToast } from '@/utils/'
|
import { showToast, formatAmount } from '@/utils/'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -139,11 +137,11 @@ const video_link = computed(() => {
|
|||||||
|
|
||||||
function enrollStudent() {
|
function enrollStudent() {
|
||||||
if (!user.data) {
|
if (!user.data) {
|
||||||
createToast({
|
showToast(
|
||||||
title: 'Please Login',
|
__('Please Login'),
|
||||||
icon: 'alert-circle',
|
__('You need to login first to enroll for this course'),
|
||||||
iconClasses: 'text-yellow-600 bg-yellow-100',
|
'circle-warn'
|
||||||
})
|
)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||||
}, 2000)
|
}, 2000)
|
||||||
@@ -159,11 +157,11 @@ function enrollStudent() {
|
|||||||
capture('enrolled_in_course', {
|
capture('enrolled_in_course', {
|
||||||
course: props.course.data.name,
|
course: props.course.data.name,
|
||||||
})
|
})
|
||||||
createToast({
|
showToast(
|
||||||
title: 'Enrolled Successfully',
|
__('Success'),
|
||||||
icon: 'check',
|
__('You have been enrolled in this course'),
|
||||||
iconClasses: 'text-green-600 bg-green-100',
|
'check'
|
||||||
})
|
)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Lesson',
|
name: 'Lesson',
|
||||||
@@ -173,7 +171,7 @@ function enrollStudent() {
|
|||||||
lessonNumber: 1,
|
lessonNumber: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, 3000)
|
}, 2000)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +204,6 @@ const certificate = createResource({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
console.log(data)
|
|
||||||
window.open(
|
window.open(
|
||||||
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
|
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
|
||||||
data.name
|
data.name
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
<Trash2
|
<Trash2
|
||||||
v-if="allowEdit"
|
v-if="allowEdit"
|
||||||
@click.prevent="trashLesson(lesson.name, chapter.name)"
|
@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
|
<Check
|
||||||
v-if="lesson.is_complete"
|
v-if="lesson.is_complete"
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, createResource } from 'frappe-ui'
|
import { Button, createResource } from 'frappe-ui'
|
||||||
import { ref } from 'vue'
|
import { ref, getCurrentInstance } from 'vue'
|
||||||
import Draggable from 'vuedraggable'
|
import Draggable from 'vuedraggable'
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||||
import {
|
import {
|
||||||
@@ -138,6 +138,8 @@ const route = useRoute()
|
|||||||
const expandAll = ref(true)
|
const expandAll = ref(true)
|
||||||
const showChapterModal = ref(false)
|
const showChapterModal = ref(false)
|
||||||
const currentChapter = ref(null)
|
const currentChapter = ref(null)
|
||||||
|
const app = getCurrentInstance()
|
||||||
|
const { $dialog } = app.appContext.config.globalProperties
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -202,9 +204,23 @@ const updateLessonIndex = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const trashLesson = (lessonName, chapterName) => {
|
const trashLesson = (lessonName, chapterName) => {
|
||||||
deleteLesson.submit({
|
$dialog({
|
||||||
lesson: lessonName,
|
title: __('Delete Lesson'),
|
||||||
chapter: chapterName,
|
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()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const props = defineProps({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
avg_rating: {
|
avg_rating: {
|
||||||
type: Number,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
membership: {
|
membership: {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<iframe
|
<iframe
|
||||||
:src="getPDFSource(block)"
|
:src="getPDFSource(block)"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="400"
|
height="700px"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
></iframe>
|
></iframe>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div
|
<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')"
|
@click="openHelpDialog('upload')"
|
||||||
>
|
>
|
||||||
<span class="leading-5">
|
<span class="leading-5">
|
||||||
@@ -56,6 +56,21 @@
|
|||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<ExplanationVideos v-model="showExplanation" :type="type" />
|
<ExplanationVideos v-model="showExplanation" :type="type" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,33 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<Button
|
<div class="flex items-center justify-between mb-5">
|
||||||
v-if="user.data.is_moderator"
|
<div class="text-lg font-semibold">
|
||||||
variant="solid"
|
{{ __('Live Class') }}
|
||||||
class="float-right mb-5"
|
</div>
|
||||||
@click="openLiveClassModal"
|
<Button v-if="user.data.is_moderator" @click="openLiveClassModal">
|
||||||
>
|
<template #prefix>
|
||||||
<template #prefix>
|
<Plus class="h-4 w-4" />
|
||||||
<Plus class="h-4 w-4" />
|
</template>
|
||||||
</template>
|
<span>
|
||||||
<span>
|
{{ __('Add') }}
|
||||||
{{ __('Add Live Class') }}
|
</span>
|
||||||
</span>
|
</Button>
|
||||||
</Button>
|
|
||||||
<div class="text-lg font-semibold mb-5">
|
|
||||||
{{ __('Live Class') }}
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
||||||
<div
|
<div
|
||||||
v-for="cls in liveClasses.data"
|
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 }}
|
{{ cls.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="leading-5 text-gray-700 text-sm mb-4">
|
||||||
{{ cls.description }}
|
{{ cls.description }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center mb-2">
|
<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">
|
<span class="ml-2">
|
||||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -38,8 +35,9 @@
|
|||||||
{{ formatTime(cls.time) }}
|
{{ formatTime(cls.time) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<a
|
||||||
|
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||||
:href="cls.start_url"
|
:href="cls.start_url"
|
||||||
target="_blank"
|
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-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
|
<a
|
||||||
:href="cls.join_url"
|
:href="cls.join_url"
|
||||||
target="_blank"
|
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" />
|
<Video class="h-4 w-4 stroke-1.5" />
|
||||||
{{ __('Join') }}
|
{{ __('Join') }}
|
||||||
@@ -90,7 +88,6 @@ const liveClasses = createListResource({
|
|||||||
doctype: 'LMS Live Class',
|
doctype: 'LMS Live Class',
|
||||||
filters: {
|
filters: {
|
||||||
batch_name: props.batch,
|
batch_name: props.batch,
|
||||||
date: ['>=', new Date()],
|
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
'title',
|
'title',
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="sidebarSettings.data"
|
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="{
|
:style="{
|
||||||
gridTemplateColumns: `repeat(${sidebarLinks.length}, minmax(0, 1fr))`,
|
gridTemplateColumns: `repeat(${
|
||||||
|
sidebarLinks.length + 1
|
||||||
|
}, minmax(0, 1fr))`,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -23,15 +25,46 @@
|
|||||||
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
|
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
|
||||||
/>
|
/>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { getSidebarLinks } from '../utils'
|
import { getSidebarLinks } from '../utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { computed, ref, onMounted } from 'vue'
|
import { watch, ref, onMounted } from 'vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
|
import { Popover } from 'frappe-ui'
|
||||||
import * as icons from 'lucide-vue-next'
|
import * as icons from 'lucide-vue-next'
|
||||||
|
|
||||||
const { logout, user, sidebarSettings } = sessionStore()
|
const { logout, user, sidebarSettings } = sessionStore()
|
||||||
@@ -39,6 +72,7 @@ let { isLoggedIn } = sessionStore()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
let { userResource } = usersStore()
|
let { userResource } = usersStore()
|
||||||
const sidebarLinks = ref(getSidebarLinks())
|
const sidebarLinks = ref(getSidebarLinks())
|
||||||
|
const otherLinks = ref([])
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
sidebarSettings.reload(
|
sidebarSettings.reload(
|
||||||
@@ -52,37 +86,53 @@ onMounted(() => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
addAccessLinks()
|
|
||||||
|
addOtherLinks()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const addAccessLinks = () => {
|
const addOtherLinks = () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
sidebarLinks.value.push({
|
otherLinks.value.push({
|
||||||
|
label: 'Notifications',
|
||||||
|
icon: 'Bell',
|
||||||
|
to: 'Notifications',
|
||||||
|
})
|
||||||
|
otherLinks.value.push({
|
||||||
label: 'Profile',
|
label: 'Profile',
|
||||||
icon: 'UserRound',
|
icon: 'UserRound',
|
||||||
activeFor: [
|
|
||||||
'Profile',
|
|
||||||
'ProfileAbout',
|
|
||||||
'ProfileCertification',
|
|
||||||
'ProfileEvaluator',
|
|
||||||
'ProfileRoles',
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
sidebarLinks.value.push({
|
otherLinks.value.push({
|
||||||
label: 'Log out',
|
label: 'Log out',
|
||||||
icon: 'LogOut',
|
icon: 'LogOut',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
sidebarLinks.value.push({
|
otherLinks.value.push({
|
||||||
label: 'Log in',
|
label: 'Log in',
|
||||||
icon: 'LogIn',
|
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) => {
|
let isActive = (tab) => {
|
||||||
return tab.activeFor?.includes(router.currentRoute.value.name)
|
return tab.activeFor?.includes(router.currentRoute.value.name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
|
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { createToast } from '@/utils/'
|
import { showToast } from '@/utils/'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
|
|
||||||
@@ -94,22 +94,14 @@ const makeAnnouncement = (close) => {
|
|||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
close()
|
close()
|
||||||
createToast({
|
showToast(
|
||||||
title: 'Success',
|
__('Success'),
|
||||||
text: 'Announcement has been sent successfully',
|
__('Announcement has been sent successfully'),
|
||||||
icon: 'Check',
|
'check'
|
||||||
iconClasses: 'bg-green-600 text-white rounded-md p-px',
|
)
|
||||||
})
|
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
createToast({
|
showToast(__('Error'), __(err.messages?.[0] || err), 'check')
|
||||||
title: 'Error',
|
|
||||||
text: err.messages?.[0] || err,
|
|
||||||
icon: 'x',
|
|
||||||
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
|
||||||
position: 'top-center',
|
|
||||||
timeout: 10,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
v-model="show"
|
v-model="show"
|
||||||
:options="{
|
:options="{
|
||||||
title: __('Add Chapter'),
|
title: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: chapterDetail ? __('Edit Chapter') : __('Add Chapter'),
|
label: chapterDetail ? __('Edit') : __('Create'),
|
||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
onClick: (close) =>
|
onClick: (close) =>
|
||||||
chapterDetail ? editChapter(close) : addChapter(close),
|
chapterDetail ? editChapter(close) : addChapter(close),
|
||||||
|
|||||||
@@ -131,10 +131,16 @@ function submitEvaluation(close) {
|
|||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
let message = err.messages?.[0] || 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({
|
createToast({
|
||||||
title: unavailabilityMessage ? 'Evaluator is Unavailable' : 'Error',
|
title: unavailabilityMessage ? __('Evaluator is Unavailable') : '',
|
||||||
text: message,
|
text: message,
|
||||||
icon: unavailabilityMessage ? 'alert-circle' : 'x',
|
icon: unavailabilityMessage ? 'alert-circle' : 'x',
|
||||||
iconClasses: 'bg-yellow-600 text-white rounded-md p-px',
|
iconClasses: 'bg-yellow-600 text-white rounded-md p-px',
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
:label="__('Type')"
|
:label="__('Type')"
|
||||||
v-model="question.type"
|
v-model="question.type"
|
||||||
type="select"
|
type="select"
|
||||||
:options="['Choices', 'User Input']"
|
:options="['Choices', 'User Input', 'Open Ended']"
|
||||||
class="pb-2"
|
class="pb-2"
|
||||||
/>
|
/>
|
||||||
<div v-if="question.type == 'Choices'" class="divide-y border-t">
|
<div v-if="question.type == 'Choices'" class="divide-y border-t">
|
||||||
@@ -74,7 +74,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<FormControl
|
||||||
:label="__('Possibility') + ' ' + n"
|
:label="__('Possibility') + ' ' + n"
|
||||||
v-model="question[`possibility_${n}`]"
|
v-model="question[`possibility_${n}`]"
|
||||||
|
|||||||
@@ -45,6 +45,20 @@
|
|||||||
:label="activeTab.label"
|
:label="activeTab.label"
|
||||||
:description="activeTab.description"
|
: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
|
<SettingDetails
|
||||||
v-else
|
v-else
|
||||||
:fields="activeTab.fields"
|
:fields="activeTab.fields"
|
||||||
@@ -58,13 +72,15 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, createDocumentResource } from 'frappe-ui'
|
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useSettings } from '@/stores/settings'
|
import { useSettings } from '@/stores/settings'
|
||||||
import SettingDetails from '../SettingDetails.vue'
|
import SettingDetails from '../SettingDetails.vue'
|
||||||
import SidebarLink from '@/components/SidebarLink.vue'
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
import Members from '@/components/Members.vue'
|
import Members from '@/components/Members.vue'
|
||||||
import Categories from '@/components/Categories.vue'
|
import Categories from '@/components/Categories.vue'
|
||||||
|
import BrandSettings from '@/components/BrandSettings.vue'
|
||||||
|
import PaymentSettings from '@/components/PaymentSettings.vue'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const doctype = ref('LMS Settings')
|
const doctype = ref('LMS Settings')
|
||||||
@@ -79,6 +95,12 @@ const data = createDocumentResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const branding = createResource({
|
||||||
|
url: 'lms.lms.api.get_branding',
|
||||||
|
auto: true,
|
||||||
|
cache: 'brand',
|
||||||
|
})
|
||||||
|
|
||||||
const tabsStructure = computed(() => {
|
const tabsStructure = computed(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -92,17 +114,6 @@ const tabsStructure = computed(() => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Settings',
|
|
||||||
hideLabel: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: 'Categories',
|
|
||||||
description: 'Manage the members of your learning system',
|
|
||||||
icon: 'Network',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
hideLabel: true,
|
hideLabel: true,
|
||||||
@@ -114,14 +125,10 @@ const tabsStructure = computed(() => {
|
|||||||
'Configure the payment gateway and other payment related settings',
|
'Configure the payment gateway and other payment related settings',
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
label: 'Razorpay Key',
|
label: 'Payment Gateway',
|
||||||
name: 'razorpay_key',
|
name: 'payment_gateway',
|
||||||
type: 'text',
|
type: 'Link',
|
||||||
},
|
doctype: 'Payment Gateway',
|
||||||
{
|
|
||||||
label: 'Razorpay Secret',
|
|
||||||
name: 'razorpay_secret',
|
|
||||||
type: 'password',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Default Currency',
|
label: 'Default Currency',
|
||||||
@@ -129,9 +136,6 @@ const tabsStructure = computed(() => {
|
|||||||
type: 'Link',
|
type: 'Link',
|
||||||
doctype: 'Currency',
|
doctype: 'Currency',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: 'Column Break',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Apply GST for India',
|
label: 'Apply GST for India',
|
||||||
name: 'apply_gst',
|
name: 'apply_gst',
|
||||||
@@ -151,10 +155,64 @@ const tabsStructure = computed(() => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
hideLabel: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Categories',
|
||||||
|
description: 'Manage the members of your learning system',
|
||||||
|
icon: 'Network',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Customise',
|
label: 'Customise',
|
||||||
hideLabel: false,
|
hideLabel: false,
|
||||||
items: [
|
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',
|
label: 'Sidebar',
|
||||||
icon: 'PanelLeftIcon',
|
icon: 'PanelLeftIcon',
|
||||||
@@ -198,7 +256,6 @@ const tabsStructure = computed(() => {
|
|||||||
{
|
{
|
||||||
label: 'Email Templates',
|
label: 'Email Templates',
|
||||||
icon: 'MailPlus',
|
icon: 'MailPlus',
|
||||||
description: 'Create email templates with the content you want',
|
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
label: 'Batch Confirmation Template',
|
label: 'Batch Confirmation Template',
|
||||||
@@ -232,9 +289,11 @@ const tabsStructure = computed(() => {
|
|||||||
rows: 10,
|
rows: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Ask user category',
|
label: 'Ask for Occupation',
|
||||||
name: 'user_category',
|
name: 'user_category',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
description:
|
||||||
|
'Enable this option to ask users to select their occupation during the signup process.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
109
frontend/src/components/PaymentSettings.vue
Normal file
109
frontend/src/components/PaymentSettings.vue
Normal 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>
|
||||||
@@ -1,11 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="quiz.data">
|
<div v-if="quiz.data">
|
||||||
<div class="bg-blue-100 py-2 px-2 mb-4 rounded-md text-sm text-blue-800">
|
<div
|
||||||
<div class="leading-relaxed">
|
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)
|
__('This quiz consists of {0} questions.').format(questions.length)
|
||||||
}}
|
}}
|
||||||
</div>
|
</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">
|
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
|
||||||
{{
|
{{
|
||||||
__(
|
__(
|
||||||
@@ -22,14 +38,16 @@
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
<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 v-if="activeQuestion == 0">
|
||||||
<div class="border text-center p-20 rounded-md">
|
<div class="border text-center p-20 rounded-md">
|
||||||
<div class="font-semibold text-lg">
|
<div class="font-semibold text-lg">
|
||||||
@@ -63,19 +81,12 @@
|
|||||||
class="border rounded-md p-5"
|
class="border rounded-md p-5"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<div class="text-sm">
|
<div class="text-sm text-gray-600">
|
||||||
<span class="mr-2">
|
<span class="mr-2">
|
||||||
{{ __('Question {0}').format(activeQuestion) }}:
|
{{ __('Question {0}').format(activeQuestion) }}:
|
||||||
</span>
|
</span>
|
||||||
<span v-if="questionDetails.data.type == 'User Input'">
|
<span>
|
||||||
{{ __('Type your answer') }}
|
{{ getInstructions(questionDetails.data) }}
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
{{
|
|
||||||
questionDetails.data.multiple
|
|
||||||
? __('Choose all answers that apply')
|
|
||||||
: __('Choose one answer')
|
|
||||||
}}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-900 text-sm font-semibold item-left">
|
<div class="text-gray-900 text-sm font-semibold item-left">
|
||||||
@@ -139,7 +150,7 @@
|
|||||||
{{ questionDetails.data[`explanation_${index}`] }}
|
{{ questionDetails.data[`explanation_${index}`] }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else-if="questionDetails.data.type == 'User Input'">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="possibleAnswer"
|
v-model="possibleAnswer"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
@@ -159,8 +170,18 @@
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mt-5">
|
<div v-else>
|
||||||
<div>
|
<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(
|
__('Question {0} of {1}').format(
|
||||||
activeQuestion,
|
activeQuestion,
|
||||||
@@ -169,7 +190,11 @@
|
|||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
v-if="quiz.data.show_answers && !showAnswers.length"
|
v-if="
|
||||||
|
quiz.data.show_answers &&
|
||||||
|
!showAnswers.length &&
|
||||||
|
questionDetails.data.type != 'Open Ended'
|
||||||
|
"
|
||||||
@click="checkAnswer()"
|
@click="checkAnswer()"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
@@ -193,11 +218,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div class="text-lg font-semibold">
|
||||||
{{ __('Quiz Summary') }}
|
{{ __('Quiz Summary') }}
|
||||||
</div>
|
</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}'
|
'You got {0}% correct answers with a score of {1} out of {2}'
|
||||||
@@ -236,20 +268,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Badge, Button, createResource, ListView } from 'frappe-ui'
|
import {
|
||||||
import { ref, watch, reactive, inject } from 'vue'
|
Badge,
|
||||||
|
Button,
|
||||||
|
createResource,
|
||||||
|
ListView,
|
||||||
|
TextEditor,
|
||||||
|
FormControl,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { ref, watch, reactive, inject, computed } from 'vue'
|
||||||
import { createToast } from '@/utils/'
|
import { createToast } from '@/utils/'
|
||||||
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
||||||
import { timeAgo } from '@/utils'
|
import { timeAgo } from '@/utils'
|
||||||
import FormControl from 'frappe-ui/src/components/FormControl.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
const user = inject('$user')
|
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
const activeQuestion = ref(0)
|
const activeQuestion = ref(0)
|
||||||
const currentQuestion = ref('')
|
const currentQuestion = ref('')
|
||||||
const selectedOptions = reactive([0, 0, 0, 0])
|
const selectedOptions = reactive([0, 0, 0, 0])
|
||||||
const showAnswers = reactive([])
|
const showAnswers = reactive([])
|
||||||
let questions = reactive([])
|
let questions = reactive([])
|
||||||
const possibleAnswer = ref(null)
|
const possibleAnswer = ref(null)
|
||||||
|
const timer = ref(0)
|
||||||
|
let timerInterval = null
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
quizName: {
|
quizName: {
|
||||||
@@ -270,6 +311,7 @@ const quiz = createResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
populateQuestions()
|
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) => {
|
const shuffleArray = (array) => {
|
||||||
for (let i = array.length - 1; i > 0; i--) {
|
for (let i = array.length - 1; i > 0; i--) {
|
||||||
const j = Math.floor(Math.random() * (i + 1))
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
@@ -369,6 +442,7 @@ watch(
|
|||||||
const startQuiz = () => {
|
const startQuiz = () => {
|
||||||
activeQuestion.value = 1
|
activeQuestion.value = 1
|
||||||
localStorage.removeItem(quiz.data.title)
|
localStorage.removeItem(quiz.data.title)
|
||||||
|
if (quiz.data.duration) startTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
const markAnswer = (index) => {
|
const markAnswer = (index) => {
|
||||||
@@ -450,9 +524,10 @@ const addToLocalStorage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextQuetion = () => {
|
const nextQuetion = () => {
|
||||||
if (!quiz.data.show_answers) {
|
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
|
||||||
checkAnswer()
|
checkAnswer()
|
||||||
} else {
|
} else {
|
||||||
|
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
|
||||||
resetQuestion()
|
resetQuestion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -467,7 +542,8 @@ const resetQuestion = () => {
|
|||||||
|
|
||||||
const submitQuiz = () => {
|
const submitQuiz = () => {
|
||||||
if (!quiz.data.show_answers) {
|
if (!quiz.data.show_answers) {
|
||||||
checkAnswer()
|
if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
|
||||||
|
else checkAnswer()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
createSubmission()
|
createSubmission()
|
||||||
}, 500)
|
}, 500)
|
||||||
@@ -477,9 +553,15 @@ const submitQuiz = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createSubmission = () => {
|
const createSubmission = () => {
|
||||||
quizSubmission.reload().then(() => {
|
quizSubmission.submit(
|
||||||
if (quiz.data && quiz.data.max_attempts) attempts.reload()
|
{},
|
||||||
})
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
if (quiz.data && quiz.data.max_attempts) attempts.reload()
|
||||||
|
if (quiz.data.duration) clearInterval(timerInterval)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetQuiz = () => {
|
const resetQuiz = () => {
|
||||||
@@ -488,6 +570,14 @@ const resetQuiz = () => {
|
|||||||
showAnswers.length = 0
|
showAnswers.length = 0
|
||||||
quizSubmission.reset()
|
quizSubmission.reset()
|
||||||
populateQuestions()
|
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 = () => {
|
const getSubmissionColumns = () => {
|
||||||
|
|||||||
@@ -1,53 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col justify-between h-full">
|
<div class="flex flex-col justify-between h-full">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold mb-1">
|
<div class="flex itemsc-center justify-between">
|
||||||
{{ __(label) }}
|
<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>
|
||||||
<div class="text-xs text-gray-600">
|
<div class="text-xs text-gray-600">
|
||||||
{{ __(description) }}
|
{{ __(description) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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="field.value"
|
|
||||||
:doctype="field.doctype"
|
|
||||||
:label="field.label"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Codemirror
|
<SettingFields :fields="fields" :data="data.doc" />
|
||||||
v-else-if="field.type == 'Code'"
|
|
||||||
v-model:value="field.value"
|
|
||||||
:label="field.label"
|
|
||||||
:height="200"
|
|
||||||
:options="{
|
|
||||||
mode: field.mode,
|
|
||||||
theme: 'seti',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormControl
|
|
||||||
v-else
|
|
||||||
:key="field.name"
|
|
||||||
v-model="field.value"
|
|
||||||
:label="field.label"
|
|
||||||
:type="field.type"
|
|
||||||
:rows="field.rows"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row-reverse mt-auto">
|
<div class="flex flex-row-reverse mt-auto">
|
||||||
<Button variant="solid" :loading="data.save.loading" @click="update">
|
<Button variant="solid" :loading="data.save.loading" @click="update">
|
||||||
{{ __('Update') }}
|
{{ __('Update') }}
|
||||||
@@ -57,12 +27,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { FormControl, Button } from 'frappe-ui'
|
import { Button, Badge } from 'frappe-ui'
|
||||||
import { computed } from 'vue'
|
import SettingFields from '@/components/SettingFields.vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
|
||||||
import Codemirror from 'codemirror-editor-vue3'
|
|
||||||
import 'codemirror/theme/seti.css'
|
|
||||||
import 'codemirror/mode/htmlmixed/htmlmixed.js'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
fields: {
|
fields: {
|
||||||
@@ -82,40 +48,16 @@ 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 = () => {
|
const update = () => {
|
||||||
props.fields.forEach((f) => {
|
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()
|
props.data.save.submit()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.CodeMirror pre.CodeMirror-line,
|
.CodeMirror pre.CodeMirror-line,
|
||||||
.CodeMirror pre.CodeMirror-line-like {
|
.CodeMirror pre.CodeMirror-line-like {
|
||||||
|
|||||||
144
frontend/src/components/SettingFields.vue
Normal file
144
frontend/src/components/SettingFields.vue
Normal 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>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
: 'ml-2 w-auto opacity-100'
|
: 'ml-2 w-auto opacity-100'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ link.label }}
|
{{ __(link.label) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
|
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
|
||||||
{{ link.count }}
|
{{ link.count }}
|
||||||
|
|||||||
@@ -11,11 +11,11 @@
|
|||||||
: 'hover:bg-gray-200 px-2 w-52'
|
: 'hover:bg-gray-200 px-2 w-52'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<span
|
<img
|
||||||
v-if="branding.data?.brand_html"
|
v-if="branding.data?.banner_image"
|
||||||
v-html="branding.data?.brand_html"
|
:src="branding.data?.banner_image.file_url"
|
||||||
class="w-8 h-8 rounded flex-shrink-0"
|
class="w-8 h-8 rounded flex-shrink-0"
|
||||||
></span>
|
/>
|
||||||
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
|
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
|
||||||
<div
|
<div
|
||||||
class="flex flex-1 flex-col text-left duration-300 ease-in-out"
|
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">
|
<div class="text-base font-medium text-gray-900 leading-none">
|
||||||
<span
|
<span
|
||||||
v-if="
|
v-if="
|
||||||
branding.data?.brand_name &&
|
branding.data?.app_name && branding.data?.app_name != 'Frappe'
|
||||||
branding.data?.brand_name != 'Frappe'
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ branding.data?.brand_name }}
|
{{ branding.data?.app_name }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else> Learning </span>
|
<span v-else> Learning </span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
<video
|
<video
|
||||||
@timeupdate="updateTime"
|
@timeupdate="updateTime"
|
||||||
@ended="videoEnded"
|
@ended="videoEnded"
|
||||||
class="rounded-lg border border-gray-100"
|
@click="togglePlay"
|
||||||
|
class="rounded-lg border border-gray-100 group cursor-pointer"
|
||||||
ref="videoRef"
|
ref="videoRef"
|
||||||
>
|
>
|
||||||
<source :src="fileURL" :type="type" />
|
<source :src="fileURL" :type="type" />
|
||||||
</video>
|
</video>
|
||||||
<div
|
<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">
|
<Button variant="ghost">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -106,6 +107,14 @@ const pauseVideo = () => {
|
|||||||
playing.value = false
|
playing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (playing.value) {
|
||||||
|
pauseVideo()
|
||||||
|
} else {
|
||||||
|
playVideo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const videoEnded = () => {
|
const videoEnded = () => {
|
||||||
playing.value = false
|
playing.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import router from './router'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import dayjs from '@/utils/dayjs'
|
import dayjs from '@/utils/dayjs'
|
||||||
|
import { createDialog } from '@/utils/dialogs'
|
||||||
import translationPlugin from './translation'
|
import translationPlugin from './translation'
|
||||||
import { usersStore } from './stores/user'
|
import { usersStore } from './stores/user'
|
||||||
import { sessionStore } from './stores/session'
|
import { sessionStore } from './stores/session'
|
||||||
@@ -36,3 +37,4 @@ let { isLoggedIn } = sessionStore()
|
|||||||
app.provide('$user', userResource)
|
app.provide('$user', userResource)
|
||||||
app.provide('$allUsers', allUsers)
|
app.provide('$allUsers', allUsers)
|
||||||
app.config.globalProperties.$user = userResource
|
app.config.globalProperties.$user = userResource
|
||||||
|
app.config.globalProperties.$dialog = createDialog
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ const breadcrumbs = computed(() => {
|
|||||||
const isStudent = computed(() => {
|
const isStudent = computed(() => {
|
||||||
return (
|
return (
|
||||||
user?.data &&
|
user?.data &&
|
||||||
batch.data?.students.length &&
|
batch.data?.students?.length &&
|
||||||
batch.data?.students.includes(user.data.name)
|
batch.data?.students.includes(user.data.name)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,57 +32,65 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div>
|
<div class="text-xs text-gray-600 mb-2">
|
||||||
<FileUploader
|
{{ __('Meta Image') }}
|
||||||
v-if="!batch.image"
|
</div>
|
||||||
class="mt-4"
|
<FileUploader
|
||||||
:fileTypes="['image/*']"
|
v-if="!batch.image"
|
||||||
:validateFile="validateFile"
|
:fileTypes="['image/*']"
|
||||||
@success="(file) => saveImage(file)"
|
:validateFile="validateFile"
|
||||||
>
|
@success="(file) => saveImage(file)"
|
||||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
>
|
||||||
<div class="mb-4">
|
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||||
<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="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="border rounded-md p-2 mr-2">
|
<div class="border rounded-md w-fit py-5 px-20">
|
||||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
<Image class="size-5 stroke-1 text-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="ml-4">
|
||||||
<span>
|
<Button @click="openFileSelector">
|
||||||
{{ batch.image.file_name }}
|
{{ __('Upload') }}
|
||||||
</span>
|
</Button>
|
||||||
<span class="text-sm text-gray-500 mt-1">
|
<div class="mt-2 text-gray-600 text-sm">
|
||||||
{{ getFileSize(batch.image.file_size) }}
|
{{
|
||||||
</span>
|
__(
|
||||||
|
'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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MultiSelect
|
|
||||||
v-model="instructors"
|
|
||||||
doctype="User"
|
|
||||||
:label="__('Instructors')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<MultiSelect
|
||||||
|
v-model="instructors"
|
||||||
|
doctype="User"
|
||||||
|
:label="__('Instructors')"
|
||||||
|
/>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.description"
|
v-model="batch.description"
|
||||||
:label="__('Description')"
|
:label="__('Description')"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
class="my-4"
|
class="my-4"
|
||||||
|
:placeholder="__('Short description of the batch')"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-600 mb-1">
|
<label class="block text-sm text-gray-600 mb-1">
|
||||||
@@ -133,6 +141,7 @@
|
|||||||
v-model="batch.timezone"
|
v-model="batch.timezone"
|
||||||
:label="__('Timezone')"
|
:label="__('Timezone')"
|
||||||
type="text"
|
type="text"
|
||||||
|
:placeholder="__('Example: IST (+5:30)')"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,6 +158,7 @@
|
|||||||
:label="__('Seat Count')"
|
:label="__('Seat Count')"
|
||||||
type="number"
|
type="number"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
|
:placeholder="__('Number of seats available')"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.evaluation_end_date"
|
v-model="batch.evaluation_end_date"
|
||||||
@@ -228,11 +238,11 @@ import {
|
|||||||
createResource,
|
createResource,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { getFileSize, showToast } from '../utils'
|
import { showToast } from '../utils'
|
||||||
import { X, FileText } from 'lucide-vue-next'
|
import { Image } from 'lucide-vue-next'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4 stroke-1.5" />
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New Batch') }}
|
{{ __('New') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
{{ __('Loading Batches...') }}
|
{{ __('Loading Batches...') }}
|
||||||
</div>
|
</div>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
v-if="hasBatches"
|
||||||
v-model="tabIndex"
|
v-model="tabIndex"
|
||||||
:tabs="makeTabs"
|
:tabs="makeTabs"
|
||||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||||
@@ -79,24 +80,63 @@
|
|||||||
<BatchCard :batch="batch" />
|
<BatchCard :batch="batch" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-else class="p-5 italic text-gray-500">
|
||||||
v-else
|
{{ __('No {0} batches').format(tab.label.toLowerCase()) }}
|
||||||
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Tabs>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
createListResource,
|
|
||||||
createResource,
|
createResource,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
@@ -104,13 +144,14 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Select,
|
Select,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { Plus } from 'lucide-vue-next'
|
import { BookOpen, Plus } from 'lucide-vue-next'
|
||||||
import BatchCard from '@/components/BatchCard.vue'
|
import BatchCard from '@/components/BatchCard.vue'
|
||||||
import { inject, ref, computed, onMounted, watch } from 'vue'
|
import { inject, ref, computed, onMounted, watch } from 'vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const currentCategory = ref(null)
|
const currentCategory = ref(null)
|
||||||
|
const hasBatches = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
let queries = new URLSearchParams(location.search)
|
let queries = new URLSearchParams(location.search)
|
||||||
@@ -119,10 +160,10 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const batches = createListResource({
|
const batches = createResource({
|
||||||
doctype: 'LMS Batch',
|
doctype: 'LMS Batch',
|
||||||
url: 'lms.lms.utils.get_batches',
|
url: 'lms.lms.utils.get_batches',
|
||||||
cache: ['batches', user?.data?.email],
|
cache: ['batches', user.data?.email],
|
||||||
auto: true,
|
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(
|
watch(
|
||||||
() => currentCategory.value,
|
() => currentCategory.value,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -1,44 +1,50 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="">
|
<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
|
<div
|
||||||
v-if="access.data?.access && orderSummary.data"
|
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">
|
<!-- <div class="mb-5">
|
||||||
{{ __('Billing Details') }}
|
<div class="text-lg font-semibold">
|
||||||
</div>
|
{{ __('Address') }}
|
||||||
<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>
|
</div>
|
||||||
<div class="text-gray-600 mt-1">
|
</div> -->
|
||||||
{{ __('Review the details of your purchase.') }}
|
<div class="flex flex-col lg:flex-row justify-between">
|
||||||
</div>
|
<div
|
||||||
<div class="mt-5">
|
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">
|
>
|
||||||
<div>
|
<div class="flex items-center justify-between space-x-2">
|
||||||
|
<div class="text-gray-600">
|
||||||
|
{{ __('Ordered Item') }}
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
{{ orderSummary.data.title }}
|
{{ orderSummary.data.title }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
</div>
|
||||||
:class="{
|
<div
|
||||||
'font-semibold text-xl': !orderSummary.data.gst_applied,
|
v-if="orderSummary.data.gst_applied"
|
||||||
}"
|
class="flex items-center justify-between"
|
||||||
>
|
>
|
||||||
{{
|
<div class="text-gray-600">
|
||||||
orderSummary.data.gst_applied
|
{{ __('Original Amount') }}
|
||||||
? orderSummary.data.original_amount_formatted
|
</div>
|
||||||
: orderSummary.data.total_amount_formatted
|
<div class="">
|
||||||
}}
|
{{ orderSummary.data.original_amount_formatted }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="orderSummary.data.gst_applied"
|
v-if="orderSummary.data.gst_applied"
|
||||||
class="flex items-center justify-between mt-2"
|
class="flex items-center justify-between mt-2"
|
||||||
>
|
>
|
||||||
<div>
|
<div class="text-gray-600">
|
||||||
{{ __('GST Amount') }}
|
{{ __('GST Amount') }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -46,107 +52,89 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="orderSummary.data.gst_applied"
|
class="flex items-center justify-between border-t border-gray-400 pt-4 mt-2"
|
||||||
class="flex items-center justify-between mt-2"
|
|
||||||
>
|
>
|
||||||
<div>
|
<div class="text-lg font-semibold">
|
||||||
{{ __('Total Amount') }}
|
{{ __('Total') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold text-2xl">
|
<div class="text-lg font-semibold">
|
||||||
{{ orderSummary.data.total_amount_formatted }}
|
{{ orderSummary.data.total_amount_formatted }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xl font-semibold mt-10">
|
<div class="flex-1 lg:mr-10">
|
||||||
{{ __('Address') }}
|
<div class="mb-5">
|
||||||
</div>
|
<div class="text-lg font-semibold">
|
||||||
<div class="text-gray-600 mt-1">
|
{{ __('Address') }}
|
||||||
{{ __('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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
<div class="mt-4">
|
<div class="space-y-4">
|
||||||
<div class="mb-1.5 text-sm text-gray-700">
|
<FormControl
|
||||||
{{ __('Country') }}
|
:label="__('Billing Name')"
|
||||||
</div>
|
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
|
<Link
|
||||||
doctype="Country"
|
doctype="Country"
|
||||||
:value="billingDetails.country"
|
:value="billingDetails.country"
|
||||||
@change="(option) => changeCurrency(option)"
|
@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
|
<Link
|
||||||
doctype="LMS Source"
|
doctype="LMS Source"
|
||||||
:value="billingDetails.source"
|
:value="billingDetails.source"
|
||||||
@change="(option) => (billingDetails.source = option)"
|
@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>
|
||||||
<div v-if="billingDetails.country == 'India'" class="mt-4">
|
</div>
|
||||||
<div class="mb-1.5 text-sm text-gray-700">
|
<div class="flex items-center justify-between border-t pt-4 mt-8">
|
||||||
{{ __('GST Number') }}
|
<p class="text-gray-600">
|
||||||
</div>
|
{{
|
||||||
<Input type="text" v-model="billingDetails.gstin" />
|
__(
|
||||||
</div>
|
'Make sure to enter the right billing name as the same will be used in your invoice.'
|
||||||
<div v-if="billingDetails.country == 'India'" class="mt-4">
|
)
|
||||||
<div class="mb-1.5 text-sm text-gray-700">
|
}}
|
||||||
{{ __('Pan Number') }}
|
</p>
|
||||||
</div>
|
<Button variant="solid" size="md" @click="generatePaymentLink()">
|
||||||
<Input type="text" v-model="billingDetails.pan" />
|
{{ __('Proceed to Payment') }}
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="solid" class="mt-8" @click="generatePaymentLink()">
|
|
||||||
{{ __('Proceed to Payment') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="access.data?.message">
|
<div v-else-if="access.data?.message">
|
||||||
@@ -167,11 +155,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<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 { reactive, inject, onMounted, ref } from 'vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import NotPermitted from '@/components/NotPermitted.vue'
|
import NotPermitted from '@/components/NotPermitted.vue'
|
||||||
import { createToast } from '@/utils/'
|
import { showToast } from '@/utils/'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
|
||||||
@@ -202,8 +197,8 @@ const access = createResource({
|
|||||||
name: props.name,
|
name: props.name,
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
orderSummary.submit()
|
|
||||||
setBillingDetails(data.address)
|
setBillingDetails(data.address)
|
||||||
|
orderSummary.submit()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -224,84 +219,49 @@ const orderSummary = createResource({
|
|||||||
const billingDetails = reactive({})
|
const billingDetails = reactive({})
|
||||||
|
|
||||||
const setBillingDetails = (data) => {
|
const setBillingDetails = (data) => {
|
||||||
billingDetails.billing_name = data.billing_name || ''
|
billingDetails.billing_name = data?.billing_name || ''
|
||||||
billingDetails.address_line1 = data.address_line1 || ''
|
billingDetails.address_line1 = data?.address_line1 || ''
|
||||||
billingDetails.address_line2 = data.address_line2 || ''
|
billingDetails.address_line2 = data?.address_line2 || ''
|
||||||
billingDetails.city = data.city || ''
|
billingDetails.city = data?.city || ''
|
||||||
billingDetails.state = data.state || ''
|
billingDetails.state = data?.state || ''
|
||||||
billingDetails.country = data.country || ''
|
billingDetails.country = data?.country || ''
|
||||||
billingDetails.pincode = data.pincode || ''
|
billingDetails.pincode = data?.pincode || ''
|
||||||
billingDetails.phone = data.phone || ''
|
billingDetails.phone = data?.phone || ''
|
||||||
billingDetails.source = data.source || ''
|
billingDetails.source = data?.source || ''
|
||||||
billingDetails.gstin = data.gstin || ''
|
billingDetails.gstin = data?.gstin || ''
|
||||||
billingDetails.pan = data.pan || ''
|
billingDetails.pan = data?.pan || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentOptions = createResource({
|
const paymentLink = createResource({
|
||||||
url: 'lms.lms.utils.get_payment_options',
|
url: 'lms.lms.payments.get_payment_link',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
return {
|
return {
|
||||||
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
||||||
docname: props.name,
|
docname: props.name,
|
||||||
phone: billingDetails.phone,
|
title: orderSummary.data.title,
|
||||||
country: billingDetails.country,
|
amount: orderSummary.data.original_amount,
|
||||||
|
total_amount: orderSummary.data.amount,
|
||||||
|
currency: orderSummary.data.currency,
|
||||||
|
address: billingDetails,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const generatePaymentLink = () => {
|
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()
|
return validateAddress()
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
data.handler = (response) => {
|
window.location.href = data
|
||||||
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()
|
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showError(err)
|
showToast(__('Error'), err.messages?.[0] || err, 'x')
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,16 +16,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
v-if="course.data.avg_rating"
|
v-if="course.data.rating"
|
||||||
:text="__('Average Rating')"
|
:text="__('Average Rating')"
|
||||||
class="flex items-center"
|
class="flex items-center"
|
||||||
>
|
>
|
||||||
<Star class="h-5 w-5 text-gray-100 fill-orange-500" />
|
<Star class="h-5 w-5 text-gray-100 fill-orange-500" />
|
||||||
<span class="ml-1">
|
<span class="ml-1">
|
||||||
{{ course.data.avg_rating }}
|
{{ course.data.rating }}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<span v-if="course.data.avg_rating" class="mx-3">·</span>
|
<span v-if="course.data.rating" class="mx-3">·</span>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
v-if="course.data.enrollment_count"
|
v-if="course.data.enrollment_count"
|
||||||
:text="__('Enrolled Students')"
|
:text="__('Enrolled Students')"
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<CourseReviews
|
<CourseReviews
|
||||||
:courseName="course.data.name"
|
:courseName="course.data.name"
|
||||||
:avg_rating="course.data.avg_rating"
|
:avg_rating="course.data.rating"
|
||||||
:membership="course.data.membership"
|
:membership="course.data.membership"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +116,7 @@ const breadcrumbs = computed(() => {
|
|||||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
||||||
items.push({
|
items.push({
|
||||||
label: course?.data?.title,
|
label: course?.data?.title,
|
||||||
route: { name: 'CourseDetail', params: { course: course?.data?.name } },
|
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
||||||
})
|
})
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,15 +23,23 @@
|
|||||||
v-model="course.title"
|
v-model="course.title"
|
||||||
:label="__('Title')"
|
:label="__('Title')"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
|
:required="true"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="course.short_introduction"
|
v-model="course.short_introduction"
|
||||||
:label="__('Short Introduction')"
|
:label="__('Short Introduction')"
|
||||||
|
:placeholder="
|
||||||
|
__(
|
||||||
|
'A one line introduction to the course that appears on the course card'
|
||||||
|
)
|
||||||
|
"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
|
:required="true"
|
||||||
/>
|
/>
|
||||||
<div class="mb-4">
|
<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') }}
|
{{ __('Course Description') }}
|
||||||
|
<span class="text-red-500">*</span>
|
||||||
</div>
|
</div>
|
||||||
<TextEditor
|
<TextEditor
|
||||||
:content="course.description"
|
: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]"
|
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>
|
||||||
<FileUploader
|
<div class="mb-4">
|
||||||
v-if="!course.course_image"
|
<div class="text-xs text-gray-600 mb-2">
|
||||||
: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">
|
|
||||||
{{ __('Course Image') }}
|
{{ __('Course Image') }}
|
||||||
|
<span class="text-red-500">*</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<FileUploader
|
||||||
<div class="border rounded-md p-2 mr-2">
|
v-if="!course.course_image"
|
||||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
: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>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="course.video_link"
|
v-model="course.video_link"
|
||||||
:label="__('Preview Video')"
|
:label="__('Preview Video')"
|
||||||
|
:placeholder="
|
||||||
|
__(
|
||||||
|
'Paste the youtube link of a short video introducing the course'
|
||||||
|
)
|
||||||
|
"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@@ -104,6 +125,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="newTag"
|
v-model="newTag"
|
||||||
|
:placeholder="__('Keywords for the course')"
|
||||||
|
class="w-52"
|
||||||
@keyup.enter="updateTags()"
|
@keyup.enter="updateTags()"
|
||||||
id="tags"
|
id="tags"
|
||||||
/>
|
/>
|
||||||
@@ -121,6 +144,7 @@
|
|||||||
v-model="instructors"
|
v-model="instructors"
|
||||||
doctype="User"
|
doctype="User"
|
||||||
:label="__('Instructors')"
|
:label="__('Instructors')"
|
||||||
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="container border-t">
|
<div class="container border-t">
|
||||||
@@ -130,7 +154,7 @@
|
|||||||
<div class="grid grid-cols-3 gap-10 mb-4">
|
<div class="grid grid-cols-3 gap-10 mb-4">
|
||||||
<div
|
<div
|
||||||
v-if="user.data?.is_moderator"
|
v-if="user.data?.is_moderator"
|
||||||
class="flex flex-col space-y-3"
|
class="flex flex-col space-y-4"
|
||||||
>
|
>
|
||||||
<FormControl
|
<FormControl
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -224,14 +248,9 @@ import {
|
|||||||
reactive,
|
reactive,
|
||||||
watch,
|
watch,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import {
|
import { convertToTitleCase, showToast, updateDocumentTitle } from '@/utils'
|
||||||
convertToTitleCase,
|
|
||||||
showToast,
|
|
||||||
getFileSize,
|
|
||||||
updateDocumentTitle,
|
|
||||||
} from '@/utils'
|
|
||||||
import Link from '@/components/Controls/Link.vue'
|
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 { useRouter } from 'vue-router'
|
||||||
import CourseOutline from '@/components/CourseOutline.vue'
|
import CourseOutline from '@/components/CourseOutline.vue'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
|
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
|
||||||
/>
|
/>
|
||||||
<div class="flex space-x-2 justify-end">
|
<div class="flex space-x-2 justify-end">
|
||||||
<div class="w-44">
|
<div class="w-40 md:w-44">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-if="categories.data?.length"
|
v-if="categories.data?.length"
|
||||||
type="select"
|
type="select"
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
:placeholder="__('Category')"
|
:placeholder="__('Category')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-36">
|
<div class="w-28 md:w-36">
|
||||||
<FormControl
|
<FormControl
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
@@ -41,13 +41,14 @@
|
|||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New Course') }}
|
{{ __('New') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="">
|
<div class="">
|
||||||
<Tabs
|
<Tabs
|
||||||
|
v-if="hasCourses"
|
||||||
v-model="tabIndex"
|
v-model="tabIndex"
|
||||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||||
:tabs="makeTabs"
|
:tabs="makeTabs"
|
||||||
@@ -101,18 +102,57 @@
|
|||||||
<CourseCard :course="course" />
|
<CourseCard :course="course" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-else class="p-5 italic text-gray-500">
|
||||||
v-else
|
{{ __('No {0} courses').format(tab.label.toLowerCase()) }}
|
||||||
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Tabs>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -127,13 +167,14 @@ import {
|
|||||||
createResource,
|
createResource,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import CourseCard from '@/components/CourseCard.vue'
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
import { Plus, Search } from 'lucide-vue-next'
|
import { BookOpen, Plus, Search } from 'lucide-vue-next'
|
||||||
import { ref, computed, inject, onMounted, watch } from 'vue'
|
import { ref, computed, inject, onMounted, watch } from 'vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const currentCategory = ref(null)
|
const currentCategory = ref(null)
|
||||||
|
const hasCourses = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
let queries = new URLSearchParams(location.search)
|
let queries = new URLSearchParams(location.search)
|
||||||
@@ -223,6 +264,16 @@ const categories = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(courses, () => {
|
||||||
|
if (courses.data) {
|
||||||
|
Object.keys(courses.data).forEach((section) => {
|
||||||
|
if (courses.data[section].length) {
|
||||||
|
hasCourses.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => currentCategory.value,
|
() => currentCategory.value,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ const newJob = createResource({
|
|||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'Job Opportunity',
|
doctype: 'Job Opportunity',
|
||||||
company_logo: job.image.file_url,
|
company_logo: job.image?.file_url,
|
||||||
...job,
|
...job,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,46 +52,88 @@
|
|||||||
</header>
|
</header>
|
||||||
<div v-if="job.data" class="max-w-3xl mx-auto">
|
<div v-if="job.data" class="max-w-3xl mx-auto">
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div class="flex mb-10">
|
<div class="space-y-5 mb-10">
|
||||||
<img
|
<div class="flex items-center">
|
||||||
:src="job.data.company_logo"
|
<img
|
||||||
class="w-16 h-16 rounded-lg object-contain mr-4"
|
:src="job.data.company_logo"
|
||||||
:alt="job.data.company_name"
|
class="w-16 h-16 rounded-lg object-contain mr-4"
|
||||||
/>
|
:alt="job.data.company_name"
|
||||||
<div>
|
/>
|
||||||
<div class="text-2xl font-semibold mb-4">
|
<div class="text-2xl font-semibold mb-4">
|
||||||
{{ job.data.job_title }}
|
{{ job.data.job_title }}
|
||||||
</div>
|
</div>
|
||||||
|
</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">
|
<div class="flex items-center space-x-2">
|
||||||
<Building2 class="h-4 w-4 stroke-1.5" />
|
<span class="p-4 bg-green-50 rounded-full">
|
||||||
<span>{{ job.data.company_name }}</span>
|
<Building2 class="h-4 w-4 text-green-500" />
|
||||||
</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>
|
</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>
|
||||||
<div
|
<div
|
||||||
v-if="applicationCount.data"
|
v-if="applicationCount.data"
|
||||||
class="flex items-center space-x-2"
|
class="flex items-center space-x-2"
|
||||||
>
|
>
|
||||||
<SquareUserRound class="h-4 w-4 stroke-1.5" />
|
<span class="p-4 bg-purple-50 rounded-full">
|
||||||
<span
|
<SquareUserRound class="h-4 w-4 text-purple-500" />
|
||||||
>{{ applicationCount.data }}
|
</span>
|
||||||
{{ __('applications received') }}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,14 +17,9 @@
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<router-link
|
<Button v-if="user.data" @click="enrollStudent()" variant="solid">
|
||||||
v-if="user.data"
|
{{ __('Start Learning') }}
|
||||||
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
|
</Button>
|
||||||
>
|
|
||||||
<Button variant="solid">
|
|
||||||
{{ __('Start Learning') }}
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
<Button v-else @click="redirectToLogin()">
|
<Button v-else @click="redirectToLogin()">
|
||||||
{{ __('Login') }}
|
{{ __('Login') }}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -120,6 +115,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
|
lesson.data.instructor_content &&
|
||||||
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
|
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
|
||||||
allowInstructorContent()
|
allowInstructorContent()
|
||||||
"
|
"
|
||||||
@@ -193,7 +189,7 @@ import { createResource, Breadcrumbs, Button } from 'frappe-ui'
|
|||||||
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
|
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import CourseOutline from '@/components/CourseOutline.vue'
|
import CourseOutline from '@/components/CourseOutline.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.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 { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||||
import Discussions from '@/components/Discussions.vue'
|
import Discussions from '@/components/Discussions.vue'
|
||||||
import { getEditorTools, updateDocumentTitle } from '../utils'
|
import { getEditorTools, updateDocumentTitle } from '../utils'
|
||||||
@@ -203,6 +199,7 @@ import CourseInstructors from '@/components/CourseInstructors.vue'
|
|||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const allowDiscussions = ref(false)
|
const allowDiscussions = ref(false)
|
||||||
const editor = ref(null)
|
const editor = ref(null)
|
||||||
@@ -244,7 +241,10 @@ const lesson = createResource({
|
|||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
lessonProgress.value = data.membership?.progress
|
lessonProgress.value = data.membership?.progress
|
||||||
if (data.content) editor.value = renderEditor('editor', data.content)
|
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(
|
instructorEditor.value = renderEditor(
|
||||||
'instructor-content',
|
'instructor-content',
|
||||||
data.instructor_content
|
data.instructor_content
|
||||||
@@ -275,7 +275,7 @@ const renderEditor = (holder, content) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const markProgress = () => {
|
const markProgress = () => {
|
||||||
if (user.data && !lesson.data?.progress) {
|
if (user.data && lesson.data && !lesson.data.progress) {
|
||||||
progress.submit()
|
progress.submit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,14 +297,14 @@ const breadcrumbs = computed(() => {
|
|||||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
||||||
items.push({
|
items.push({
|
||||||
label: lesson?.data?.course_title,
|
label: lesson?.data?.course_title,
|
||||||
route: { name: 'CourseDetail', params: { course: props.courseName } },
|
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||||
})
|
})
|
||||||
items.push({
|
items.push({
|
||||||
label: lesson?.data?.title,
|
label: lesson?.data?.title,
|
||||||
route: {
|
route: {
|
||||||
name: 'Lesson',
|
name: 'Lesson',
|
||||||
params: {
|
params: {
|
||||||
course: props.courseName,
|
courseName: props.courseName,
|
||||||
chapterNumber: props.chapterNumber,
|
chapterNumber: props.chapterNumber,
|
||||||
lessonNumber: props.lessonNumber,
|
lessonNumber: props.lessonNumber,
|
||||||
},
|
},
|
||||||
@@ -375,6 +375,30 @@ const allowInstructorContent = () => {
|
|||||||
return false
|
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 = () => {
|
const redirectToLogin = () => {
|
||||||
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Breadcrumbs, FormControl, createResource, Button } from 'frappe-ui'
|
import { Breadcrumbs, Button, createResource, FormControl } from 'frappe-ui'
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
reactive,
|
reactive,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<img
|
<img
|
||||||
:src="badge.badge_image"
|
:src="badge.badge_image"
|
||||||
:alt="badge.badge"
|
: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="p-5">
|
||||||
<div class="text-2xl font-semibold mb-2">
|
<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 ${
|
const summary = `I am happy to announce that I earned the ${
|
||||||
badge.badge
|
badge.badge
|
||||||
} badge on ${dayjs(badge.issued_on).format('DD MMM YYYY')} at ${
|
} badge on ${dayjs(badge.issued_on).format('DD MMM YYYY')} at ${
|
||||||
branding.data?.brand_name
|
branding.data?.app_name
|
||||||
}.`
|
}.`
|
||||||
|
|
||||||
if (medium == 'LinkedIn')
|
if (medium == 'LinkedIn')
|
||||||
|
|||||||
@@ -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"
|
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 :items="breadcrumbs" />
|
||||||
<Button variant="solid" @click="submitQuiz()">
|
<div class="space-x-2">
|
||||||
{{ __('Save') }}
|
<router-link
|
||||||
</Button>
|
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>
|
</header>
|
||||||
<div class="w-3/4 mx-auto py-5">
|
<div class="w-3/4 mx-auto py-5">
|
||||||
<!-- Details -->
|
<!-- Details -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="text-sm font-semibold mb-4">
|
<div class="font-semibold mb-4">
|
||||||
{{ __('Details') }}
|
{{ __('Details') }}
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
<FormControl
|
||||||
@@ -22,11 +50,17 @@
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<div v-if="quizDetails.data?.name">
|
<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
|
<FormControl
|
||||||
|
type="number"
|
||||||
v-model="quiz.max_attempts"
|
v-model="quiz.max_attempts"
|
||||||
:label="__('Maximun Attempts')"
|
:label="__('Maximun Attempts')"
|
||||||
/>
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="number"
|
||||||
|
v-model="quiz.duration"
|
||||||
|
:label="__('Duration (in minutes)')"
|
||||||
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="quiz.total_marks"
|
v-model="quiz.total_marks"
|
||||||
:label="__('Total Marks')"
|
:label="__('Total Marks')"
|
||||||
@@ -40,7 +74,7 @@
|
|||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="text-sm font-semibold mb-4">
|
<div class="font-semibold mb-4">
|
||||||
{{ __('Settings') }}
|
{{ __('Settings') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-5 my-4">
|
<div class="grid grid-cols-3 gap-5 my-4">
|
||||||
@@ -58,7 +92,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="text-sm font-semibold mb-4">
|
<div class="font-semibold mb-4">
|
||||||
{{ __('Shuffle Settings') }}
|
{{ __('Shuffle Settings') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3">
|
<div class="grid grid-cols-3">
|
||||||
@@ -78,7 +112,7 @@
|
|||||||
<!-- Questions -->
|
<!-- Questions -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="text-sm font-semibold">
|
<div class="font-semibold">
|
||||||
{{ __('Questions') }}
|
{{ __('Questions') }}
|
||||||
</div>
|
</div>
|
||||||
<Button @click="openQuestionModal()">
|
<Button @click="openQuestionModal()">
|
||||||
@@ -198,6 +232,7 @@ const quiz = reactive({
|
|||||||
total_marks: 0,
|
total_marks: 0,
|
||||||
passing_percentage: 0,
|
passing_percentage: 0,
|
||||||
max_attempts: 0,
|
max_attempts: 0,
|
||||||
|
duration: 0,
|
||||||
limit_questions_to: 0,
|
limit_questions_to: 0,
|
||||||
show_answers: true,
|
show_answers: true,
|
||||||
show_submission_history: false,
|
show_submission_history: false,
|
||||||
@@ -347,17 +382,17 @@ const questionColumns = computed(() => {
|
|||||||
{
|
{
|
||||||
label: __('ID'),
|
label: __('ID'),
|
||||||
key: 'question',
|
key: 'question',
|
||||||
width: '25%',
|
width: '10rem',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: __('Question'),
|
label: __('Question'),
|
||||||
key: __('question_detail'),
|
key: __('question_detail'),
|
||||||
width: '60%',
|
width: '40rem',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: __('Marks'),
|
label: __('Marks'),
|
||||||
key: 'marks',
|
key: 'marks',
|
||||||
width: '10%',
|
width: '5rem',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
58
frontend/src/pages/QuizPage.vue
Normal file
58
frontend/src/pages/QuizPage.vue
Normal 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>
|
||||||
@@ -2,47 +2,121 @@
|
|||||||
<header
|
<header
|
||||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
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>
|
</header>
|
||||||
<div class="w-1/2 mx-auto py-10">
|
<div v-if="submisisonDetails.doc" class="w-1/2 mx-auto py-5 space-y-4">
|
||||||
<Quiz :quizName="quizID" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import Quiz from '@/components/Quiz.vue'
|
import {
|
||||||
import { createResource, Breadcrumbs } from 'frappe-ui'
|
createDocumentResource,
|
||||||
import { computed, inject, onMounted } from 'vue'
|
Breadcrumbs,
|
||||||
|
FormControl,
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, onMounted, inject } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
|
||||||
const user = inject('$user')
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data) {
|
if (!user.data?.is_instructor && !user.data?.is_moderator)
|
||||||
router.push({ name: 'Courses' })
|
router.push({ name: 'Courses' })
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
quizID: {
|
submission: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const title = createResource({
|
const submisisonDetails = createDocumentResource({
|
||||||
url: 'frappe.client.get_value',
|
doctype: 'LMS Quiz Submission',
|
||||||
params: {
|
name: props.submission,
|
||||||
doctype: 'LMS Quiz',
|
|
||||||
fieldname: 'title',
|
|
||||||
filters: {
|
|
||||||
name: props.quizID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
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>
|
</script>
|
||||||
|
|||||||
104
frontend/src/pages/QuizSubmissionList.vue
Normal file
104
frontend/src/pages/QuizSubmissionList.vue
Normal 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>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</header>
|
</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
|
<ListView
|
||||||
:columns="quizColumns"
|
:columns="quizColumns"
|
||||||
:rows="quizzes.data"
|
:rows="quizzes.data"
|
||||||
|
|||||||
@@ -160,7 +160,19 @@ const routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/quiz/:quizID',
|
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'),
|
component: () => import('@/pages/QuizSubmission.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const sessionStore = defineStore('lms-session', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let user = ref(sessionUser())
|
let user = ref(sessionUser())
|
||||||
if (user) {
|
if (user.value) {
|
||||||
allUsers.reload()
|
allUsers.reload()
|
||||||
}
|
}
|
||||||
const isLoggedIn = computed(() => !!user.value)
|
const isLoggedIn = computed(() => !!user.value)
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ export function formatNumberIntoCurrency(number, currency) {
|
|||||||
return ''
|
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) {
|
export function convertToTitleCase(str) {
|
||||||
if (!str) {
|
if (!str) {
|
||||||
return ''
|
return ''
|
||||||
@@ -82,10 +91,13 @@ export function getFileSize(file_size) {
|
|||||||
|
|
||||||
export function showToast(title, text, icon, iconClasses = null) {
|
export function showToast(title, text, icon, iconClasses = null) {
|
||||||
if (!iconClasses) {
|
if (!iconClasses) {
|
||||||
iconClasses =
|
if (icon == 'check') {
|
||||||
icon == 'check'
|
iconClasses = 'bg-green-600 text-white rounded-md p-px'
|
||||||
? 'bg-green-600 text-white rounded-md p-px'
|
} else if (icon == 'circle-warn') {
|
||||||
: 'bg-red-600 text-white rounded-md p-px'
|
iconClasses = 'bg-yellow-600 text-white rounded-md p-px'
|
||||||
|
} else {
|
||||||
|
iconClasses = 'bg-red-600 text-white rounded-md p-px'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createToast({
|
createToast({
|
||||||
title: title,
|
title: title,
|
||||||
@@ -499,3 +511,10 @@ export function singularize(word) {
|
|||||||
(r) => endings[r]
|
(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.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export class Quiz {
|
|||||||
app.mount(this.wrapper)
|
app.mount(this.wrapper)
|
||||||
return
|
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">
|
<span class="font-medium">
|
||||||
Quiz: ${quiz}
|
Quiz: ${quiz}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -56,9 +56,11 @@ export class Upload {
|
|||||||
app.mount(this.wrapper)
|
app.mount(this.wrapper)
|
||||||
return
|
return
|
||||||
} else if (file.file_type == 'PDF') {
|
} 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
|
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
|
return
|
||||||
} else {
|
} else {
|
||||||
this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI(
|
this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI(
|
||||||
|
|||||||
@@ -852,6 +852,11 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue-demi ">=0.14.8"
|
vue-demi ">=0.14.8"
|
||||||
|
|
||||||
|
ace-builds@^1.36.2:
|
||||||
|
version "1.36.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.36.2.tgz#9499bd59e839a335ac4850e74549ca8d849dc554"
|
||||||
|
integrity sha512-eqqfbGwx/GKjM/EnFu4QtQ+d2NNBu84MGgxoG8R5iyFpcVeQ4p9YlTL+ZzdEJqhdkASqoqOxCSNNGyB6lvMm+A==
|
||||||
|
|
||||||
ansi-regex@^5.0.1:
|
ansi-regex@^5.0.1:
|
||||||
version "5.0.1"
|
version "5.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
||||||
@@ -1219,10 +1224,10 @@ fraction.js@^4.3.7:
|
|||||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
||||||
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
||||||
|
|
||||||
frappe-ui@^0.1.69:
|
frappe-ui@^0.1.72:
|
||||||
version "0.1.69"
|
version "0.1.72"
|
||||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.69.tgz#bfc6d19dff97d2666c36da63f5de62f819539406"
|
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.72.tgz#f5550056ddee7ad4341f2c1825d046404d221820"
|
||||||
integrity sha512-MKHYTcRvmccZwTYlIcmf4OCbJQH5eqKXsq3Cj2lbnmoWuuTh9m7T3AoRKEwOIlZ0mSGCH9yzaF2BINBXGpIJdQ==
|
integrity sha512-XWYKmCjw3ViD+/+tZMUiYqwHFlMGMsVuazOYiN5bKlE+aiheJsnHlOOUyQswYX1Y7jNxuC7gGpSLNg2ZpXA7hA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@headlessui/vue" "^1.7.14"
|
"@headlessui/vue" "^1.7.14"
|
||||||
"@popperjs/core" "^2.11.2"
|
"@popperjs/core" "^2.11.2"
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "2.6.0"
|
__version__ = "2.10.0"
|
||||||
|
|||||||
@@ -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"),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -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"
|
|
||||||
@@ -110,7 +110,8 @@ doc_events = {
|
|||||||
# ---------------
|
# ---------------
|
||||||
scheduler_events = {
|
scheduler_events = {
|
||||||
"hourly": [
|
"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"],
|
"daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"],
|
||||||
}
|
}
|
||||||
@@ -185,6 +186,7 @@ jinja = {
|
|||||||
"lms.lms.utils.get_lesson_url",
|
"lms.lms.utils.get_lesson_url",
|
||||||
"lms.page_renderers.get_profile_url",
|
"lms.page_renderers.get_profile_url",
|
||||||
"lms.overrides.user.get_palette",
|
"lms.overrides.user.get_palette",
|
||||||
|
"lms.lms.utils.is_instructor",
|
||||||
],
|
],
|
||||||
"filters": [],
|
"filters": [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestLMSJobApplication(FrappeTestCase):
|
class TestLMSJobApplication(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
112
lms/lms/api.py
112
lms/lms/api.py
@@ -6,8 +6,9 @@ from frappe.translate import get_all_translations
|
|||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.query_builder import DocType
|
from frappe.query_builder import DocType
|
||||||
from frappe.query_builder.functions import Count
|
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 typing import Optional
|
||||||
|
from lms.lms.utils import get_average_rating, get_lesson_count
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@@ -289,11 +290,16 @@ def get_file_info(file_url):
|
|||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def get_branding():
|
def get_branding():
|
||||||
"""Get branding details."""
|
"""Get branding details."""
|
||||||
return {
|
website_settings = frappe.get_single("Website Settings")
|
||||||
"brand_name": frappe.db.get_single_value("Website Settings", "app_name"),
|
image_fields = ["banner_image", "footer_logo", "favicon"]
|
||||||
"brand_html": frappe.db.get_single_value("Website Settings", "brand_html"),
|
|
||||||
"favicon": frappe.db.get_single_value("Website Settings", "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()
|
@frappe.whitelist()
|
||||||
@@ -320,7 +326,7 @@ def get_evaluator_details(evaluator):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if frappe.db.exists("Course Evaluator", {"evaluator": 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:
|
else:
|
||||||
doc = frappe.new_doc("Course Evaluator")
|
doc = frappe.new_doc("Course Evaluator")
|
||||||
doc.evaluator = evaluator
|
doc.evaluator = evaluator
|
||||||
@@ -574,14 +580,17 @@ def get_members(start=0, search=""):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
|
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
|
||||||
|
or_filters = {}
|
||||||
|
|
||||||
if search:
|
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(
|
members = frappe.get_all(
|
||||||
"User",
|
"User",
|
||||||
filters=filters,
|
filters=filters,
|
||||||
fields=["name", "full_name", "user_image", "username", "last_active"],
|
fields=["name", "full_name", "user_image", "username", "last_active"],
|
||||||
|
or_filters=or_filters,
|
||||||
page_length=20,
|
page_length=20,
|
||||||
start=start,
|
start=start,
|
||||||
)
|
)
|
||||||
@@ -706,3 +715,90 @@ def delete_documents(doctype, documents):
|
|||||||
frappe.only_for("Moderator")
|
frappe.only_for("Moderator")
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
frappe.delete_doc(doctype, doc)
|
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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,17 +7,3 @@ from frappe.model.document import Document
|
|||||||
|
|
||||||
class BatchStudent(Document):
|
class BatchStudent(Document):
|
||||||
pass
|
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)
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestBatchStudent(FrappeTestCase):
|
class TestBatchStudent(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -9,9 +9,8 @@
|
|||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"course",
|
"course",
|
||||||
"title",
|
|
||||||
"column_break_3",
|
"column_break_3",
|
||||||
"description",
|
"title",
|
||||||
"section_break_5",
|
"section_break_5",
|
||||||
"lessons"
|
"lessons"
|
||||||
],
|
],
|
||||||
@@ -35,11 +34,6 @@
|
|||||||
"fieldname": "column_break_3",
|
"fieldname": "column_break_3",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"fieldname": "description",
|
|
||||||
"fieldtype": "Small Text",
|
|
||||||
"label": "Description"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"fieldname": "section_break_5",
|
"fieldname": "section_break_5",
|
||||||
"fieldtype": "Section Break"
|
"fieldtype": "Section Break"
|
||||||
@@ -59,7 +53,7 @@
|
|||||||
"link_fieldname": "chapter"
|
"link_fieldname": "chapter"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2023-09-29 17:03:58.013819",
|
"modified": "2024-10-29 16:54:20.904683",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "Course Chapter",
|
"name": "Course Chapter",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCourseEvaluator(FrappeTestCase):
|
class TestCourseEvaluator(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,148 +1,4 @@
|
|||||||
// Copyright (c) 2021, FOSS United and contributors
|
// Copyright (c) 2021, FOSS United and contributors
|
||||||
// For license information, please see license.txt
|
// For license information, please see license.txt
|
||||||
|
|
||||||
frappe.ui.form.on("Course Lesson", {
|
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>
|
|
||||||
`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"fieldname": "title",
|
"fieldname": "title",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1,
|
||||||
"label": "Title",
|
"label": "Title",
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
@@ -161,7 +162,7 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-04-03 10:48:17.525859",
|
"modified": "2024-10-08 11:04:54.748773",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "Course Lesson",
|
"name": "Course Lesson",
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ def save_progress(lesson, course):
|
|||||||
|
|
||||||
def capture_progress_for_analytics(progress, course):
|
def capture_progress_for_analytics(progress, course):
|
||||||
if progress in [25, 50, 75, 100]:
|
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):
|
def get_quiz_progress(lesson):
|
||||||
|
|||||||
@@ -15,20 +15,22 @@
|
|||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Assessment Type",
|
"label": "Assessment Type",
|
||||||
"options": "DocType"
|
"options": "DocType",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "assessment_name",
|
"fieldname": "assessment_name",
|
||||||
"fieldtype": "Dynamic Link",
|
"fieldtype": "Dynamic Link",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Assessment Name",
|
"label": "Assessment Name",
|
||||||
"options": "assessment_type"
|
"options": "assessment_type",
|
||||||
|
"reqd": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2023-05-29 14:56:36.602399",
|
"modified": "2024-10-11 19:16:01.630524",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Assessment",
|
"name": "LMS Assessment",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestLMSAssignment(FrappeTestCase):
|
class TestLMSAssignment(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestLMSBadge(FrappeTestCase):
|
class TestLMSBadge(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestLMSBadgeAssignment(FrappeTestCase):
|
class TestLMSBadgeAssignment(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from lms.lms.utils import (
|
|||||||
get_lesson_url,
|
get_lesson_url,
|
||||||
get_quiz_details,
|
get_quiz_details,
|
||||||
get_assignment_details,
|
get_assignment_details,
|
||||||
|
update_payment_record,
|
||||||
)
|
)
|
||||||
from frappe.email.doctype.email_template.email_template import get_email_template
|
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_batch_end_date()
|
||||||
self.validate_duplicate_courses()
|
self.validate_duplicate_courses()
|
||||||
self.validate_duplicate_students()
|
self.validate_duplicate_students()
|
||||||
|
self.validate_payments_app()
|
||||||
self.validate_duplicate_assessments()
|
self.validate_duplicate_assessments()
|
||||||
self.validate_membership()
|
self.validate_membership()
|
||||||
self.validate_timetable()
|
self.validate_timetable()
|
||||||
self.send_confirmation_mail()
|
self.send_confirmation_mail()
|
||||||
self.validate_evaluation_end_date()
|
self.validate_evaluation_end_date()
|
||||||
|
self.add_students_to_live_class()
|
||||||
|
|
||||||
def validate_batch_end_date(self):
|
def validate_batch_end_date(self):
|
||||||
if self.end_date < self.start_date:
|
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))
|
_("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):
|
def validate_duplicate_assessments(self):
|
||||||
assessments = [row.assessment_name for row in self.assessment]
|
assessments = [row.assessment_name for row in self.assessment]
|
||||||
for assessment in self.assessment:
|
for assessment in self.assessment:
|
||||||
@@ -131,6 +140,27 @@ class LMSBatch(Document):
|
|||||||
if cint(self.seat_count) < len(self.students):
|
if cint(self.seat_count) < len(self.students):
|
||||||
frappe.throw(_("There are no seats available in this batch."))
|
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):
|
def validate_timetable(self):
|
||||||
for schedule in self.timetable:
|
for schedule in self.timetable:
|
||||||
if schedule.start_time and schedule.end_time:
|
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)
|
_("Row #{0} Date cannot be outside the batch duration.").format(schedule.idx)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_payment_authorized(self, payment_status):
|
||||||
@frappe.whitelist()
|
if payment_status in ["Authorized", "Completed"]:
|
||||||
def remove_student(student, batch_name):
|
update_payment_record("LMS Batch", self.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})
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestLMSClass(FrappeTestCase):
|
class TestLMSBatch(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
class TestLMSBatchTimetable(FrappeTestCase):
|
class TestLMSBatchTimetable(unittest.TestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestLMSCategory(FrappeTestCase):
|
class TestLMSCategory(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestLMSCertificateEvaluation(FrappeTestCase):
|
class TestLMSCertificateEvaluation(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestLMSCertificateRequest(FrappeTestCase):
|
class TestLMSCertificateRequest(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -48,7 +48,12 @@
|
|||||||
"certification_section",
|
"certification_section",
|
||||||
"enable_certification",
|
"enable_certification",
|
||||||
"column_break_rxww",
|
"column_break_rxww",
|
||||||
"expiry"
|
"expiry",
|
||||||
|
"tab_4_tab",
|
||||||
|
"statistics_section",
|
||||||
|
"enrollments",
|
||||||
|
"lessons",
|
||||||
|
"rating"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@@ -249,6 +254,36 @@
|
|||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Category",
|
"label": "Category",
|
||||||
"options": "LMS 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",
|
"is_published_field": "published",
|
||||||
@@ -275,7 +310,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2024-09-21 10:23:58.633912",
|
"modified": "2024-10-30 23:08:31.842860",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Course",
|
"name": "LMS Course",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from frappe.model.document import Document
|
|||||||
from frappe.utils import cint, today
|
from frappe.utils import cint, today
|
||||||
from frappe.utils.telemetry import capture
|
from frappe.utils.telemetry import capture
|
||||||
from lms.lms.utils import get_chapters, can_create_courses
|
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 _
|
from frappe import _
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ class LMSCourse(Document):
|
|||||||
self.validate_instructors()
|
self.validate_instructors()
|
||||||
self.validate_video_link()
|
self.validate_video_link()
|
||||||
self.validate_status()
|
self.validate_status()
|
||||||
|
self.validate_payments_app()
|
||||||
self.image = validate_image(self.image)
|
self.image = validate_image(self.image)
|
||||||
|
|
||||||
def validate_published(self):
|
def validate_published(self):
|
||||||
@@ -44,10 +45,20 @@ class LMSCourse(Document):
|
|||||||
if self.published:
|
if self.published:
|
||||||
self.status = "Approved"
|
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):
|
def on_update(self):
|
||||||
if not self.upcoming and self.has_value_changed("upcoming"):
|
if not self.upcoming and self.has_value_changed("upcoming"):
|
||||||
self.send_email_to_interested_users()
|
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):
|
def send_email_to_interested_users(self):
|
||||||
interested_users = frappe.get_all(
|
interested_users = frappe.get_all(
|
||||||
"LMS Course Interest", {"course": self.name}, ["name", "user"]
|
"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 = frappe.get_doc("LMS Course", course_data["name"])
|
||||||
course.reindex_exercises()
|
course.reindex_exercises()
|
||||||
frappe.msgprint("All exercises in this course have been re-indexed.")
|
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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -75,7 +75,8 @@
|
|||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Course",
|
"label": "Course",
|
||||||
"options": "LMS Course",
|
"options": "LMS Course",
|
||||||
"reqd": 1
|
"reqd": 1,
|
||||||
|
"search_index": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "current_lesson",
|
"fieldname": "current_lesson",
|
||||||
@@ -126,7 +127,7 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-05-14 14:50:08.405033",
|
"modified": "2024-10-30 12:44:16.103598",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Enrollment",
|
"name": "LMS Enrollment",
|
||||||
|
|||||||
@@ -10,19 +10,20 @@
|
|||||||
"title",
|
"title",
|
||||||
"host",
|
"host",
|
||||||
"batch_name",
|
"batch_name",
|
||||||
|
"event",
|
||||||
"column_break_astv",
|
"column_break_astv",
|
||||||
"date",
|
|
||||||
"time",
|
|
||||||
"duration",
|
|
||||||
"section_break_glxh",
|
|
||||||
"description",
|
"description",
|
||||||
|
"section_break_glxh",
|
||||||
|
"date",
|
||||||
|
"duration",
|
||||||
"column_break_spvt",
|
"column_break_spvt",
|
||||||
|
"time",
|
||||||
"timezone",
|
"timezone",
|
||||||
"password",
|
|
||||||
"auto_recording",
|
|
||||||
"section_break_yrpq",
|
"section_break_yrpq",
|
||||||
|
"password",
|
||||||
"start_url",
|
"start_url",
|
||||||
"column_break_yokr",
|
"column_break_yokr",
|
||||||
|
"auto_recording",
|
||||||
"join_url"
|
"join_url"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -122,11 +123,18 @@
|
|||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"label": "Auto Recording",
|
"label": "Auto Recording",
|
||||||
"options": "No Recording\nLocal\nCloud"
|
"options": "No Recording\nLocal\nCloud"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "event",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Event",
|
||||||
|
"options": "Event",
|
||||||
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-01-09 11:22:33.272341",
|
"modified": "2024-10-31 15:41:35.540856",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Live Class",
|
"name": "LMS Live Class",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class LMSLiveClass(Document):
|
|||||||
if calendar:
|
if calendar:
|
||||||
event = self.create_event()
|
event = self.create_event()
|
||||||
self.add_event_participants(event, calendar)
|
self.add_event_participants(event, calendar)
|
||||||
|
frappe.db.set_value(self.doctype, self.name, "event", event.name)
|
||||||
|
|
||||||
def create_event(self):
|
def create_event(self):
|
||||||
start = f"{self.date} {self.time}"
|
start = f"{self.date} {self.time}"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestLMSLiveClass(FrappeTestCase):
|
class TestLMSLiveClass(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
"default": "0",
|
"default": "0",
|
||||||
"fieldname": "payment_received",
|
"fieldname": "payment_received",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
|
"in_standard_filter": 1,
|
||||||
"label": "Payment Received"
|
"label": "Payment Received"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -140,7 +141,7 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2023-10-26 16:54:12.408274",
|
"modified": "2024-10-31 15:33:39.420366",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Payment",
|
"name": "LMS Payment",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestLMSPayment(FrappeTestCase):
|
class TestLMSPayment(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Type",
|
"label": "Type",
|
||||||
"options": "Choices\nUser Input"
|
"options": "Choices\nUser Input\nOpen Ended"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"depends_on": "eval:doc.type == \"Choices\";",
|
"depends_on": "eval:doc.type == \"Choices\";",
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2024-08-01 12:53:22.540990",
|
"modified": "2024-10-07 09:41:17.862774",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Question",
|
"name": "LMS Question",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ def validate_correct_answers(question):
|
|||||||
if question.type == "Choices":
|
if question.type == "Choices":
|
||||||
validate_duplicate_options(question)
|
validate_duplicate_options(question)
|
||||||
validate_correct_options(question)
|
validate_correct_options(question)
|
||||||
else:
|
elif question.type == "User Input":
|
||||||
validate_possible_answer(question)
|
validate_possible_answer(question)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestLMSQuestion(FrappeTestCase):
|
class TestLMSQuestion(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -10,10 +10,11 @@
|
|||||||
"title",
|
"title",
|
||||||
"max_attempts",
|
"max_attempts",
|
||||||
"show_answers",
|
"show_answers",
|
||||||
|
"show_submission_history",
|
||||||
"column_break_gaac",
|
"column_break_gaac",
|
||||||
"total_marks",
|
"total_marks",
|
||||||
"passing_percentage",
|
"passing_percentage",
|
||||||
"show_submission_history",
|
"duration",
|
||||||
"section_break_tzbu",
|
"section_break_tzbu",
|
||||||
"shuffle_questions",
|
"shuffle_questions",
|
||||||
"column_break_clsh",
|
"column_break_clsh",
|
||||||
@@ -128,11 +129,16 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "column_break_clsh",
|
"fieldname": "column_break_clsh",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "duration",
|
||||||
|
"fieldtype": "Duration",
|
||||||
|
"label": "Duration"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-08-09 12:21:36.256522",
|
"modified": "2024-10-11 22:39:40.381183",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Quiz",
|
"name": "LMS Quiz",
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
import re
|
||||||
|
from frappe import _, safe_decode
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.utils import cstr, comma_and, cint
|
from frappe.utils import cstr, comma_and, cint
|
||||||
from fuzzywuzzy import fuzz
|
from fuzzywuzzy import fuzz
|
||||||
@@ -13,6 +14,9 @@ from lms.lms.utils import (
|
|||||||
has_course_moderator_role,
|
has_course_moderator_role,
|
||||||
has_course_instructor_role,
|
has_course_instructor_role,
|
||||||
)
|
)
|
||||||
|
from binascii import Error as BinasciiError
|
||||||
|
from frappe.utils.file_manager import safe_b64decode
|
||||||
|
from frappe.core.doctype.file.utils import get_random_filename
|
||||||
|
|
||||||
|
|
||||||
class LMSQuiz(Document):
|
class LMSQuiz(Document):
|
||||||
@@ -20,6 +24,7 @@ class LMSQuiz(Document):
|
|||||||
self.validate_duplicate_questions()
|
self.validate_duplicate_questions()
|
||||||
self.validate_limit()
|
self.validate_limit()
|
||||||
self.calculate_total_marks()
|
self.calculate_total_marks()
|
||||||
|
self.validate_open_ended_questions()
|
||||||
|
|
||||||
def validate_duplicate_questions(self):
|
def validate_duplicate_questions(self):
|
||||||
questions = [row.question for row in self.questions]
|
questions = [row.question for row in self.questions]
|
||||||
@@ -48,6 +53,19 @@ class LMSQuiz(Document):
|
|||||||
else:
|
else:
|
||||||
self.total_marks = sum(cint(question.marks) for question in self.questions)
|
self.total_marks = sum(cint(question.marks) for question in self.questions)
|
||||||
|
|
||||||
|
def validate_open_ended_questions(self):
|
||||||
|
types = [question.type for question in self.questions]
|
||||||
|
types = set(types)
|
||||||
|
|
||||||
|
if "Open Ended" in types and len(types) > 1:
|
||||||
|
frappe.throw(
|
||||||
|
_(
|
||||||
|
"If you want open ended questions then make sure each question in the quiz is of open ended type."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.show_answers = 0
|
||||||
|
|
||||||
def autoname(self):
|
def autoname(self):
|
||||||
if not self.name:
|
if not self.name:
|
||||||
self.name = generate_slug(self.title, "LMS Quiz")
|
self.name = generate_slug(self.title, "LMS Quiz")
|
||||||
@@ -81,44 +99,61 @@ def set_total_marks(questions):
|
|||||||
def quiz_summary(quiz, results):
|
def quiz_summary(quiz, results):
|
||||||
score = 0
|
score = 0
|
||||||
results = results and json.loads(results)
|
results = results and json.loads(results)
|
||||||
|
is_open_ended = False
|
||||||
|
percentage = 0
|
||||||
|
|
||||||
|
quiz_details = frappe.db.get_value(
|
||||||
|
"LMS Quiz",
|
||||||
|
quiz,
|
||||||
|
["total_marks", "passing_percentage", "lesson", "course"],
|
||||||
|
as_dict=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
score_out_of = quiz_details.total_marks
|
||||||
|
|
||||||
for result in results:
|
for result in results:
|
||||||
correct = result["is_correct"][0]
|
|
||||||
for point in result["is_correct"]:
|
|
||||||
correct = correct and point
|
|
||||||
result["is_correct"] = correct
|
|
||||||
|
|
||||||
question_details = frappe.db.get_value(
|
question_details = frappe.db.get_value(
|
||||||
"LMS Quiz Question",
|
"LMS Quiz Question",
|
||||||
{"parent": quiz, "question": result["question_name"]},
|
{"parent": quiz, "question": result["question_name"]},
|
||||||
["question", "marks", "question_detail"],
|
["question", "marks", "question_detail", "type"],
|
||||||
as_dict=1,
|
as_dict=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
result["question_name"] = question_details.question
|
result["question_name"] = question_details.question
|
||||||
result["question"] = question_details.question_detail
|
result["question"] = question_details.question_detail
|
||||||
marks = question_details.marks if correct else 0
|
result["marks_out_of"] = question_details.marks
|
||||||
|
|
||||||
result["marks"] = marks
|
if question_details.type != "Open Ended":
|
||||||
score += marks
|
correct = result["is_correct"][0]
|
||||||
|
for point in result["is_correct"]:
|
||||||
|
correct = correct and point
|
||||||
|
result["is_correct"] = correct
|
||||||
|
|
||||||
del result["question_name"]
|
marks = question_details.marks if correct else 0
|
||||||
|
result["marks"] = marks
|
||||||
|
score += marks
|
||||||
|
|
||||||
quiz_details = frappe.db.get_value(
|
del result["question_name"]
|
||||||
"LMS Quiz", quiz, ["total_marks", "passing_percentage", "lesson", "course"], as_dict=1
|
else:
|
||||||
)
|
result["is_correct"] = 0
|
||||||
score_out_of = quiz_details.total_marks
|
is_open_ended = True
|
||||||
percentage = (score / score_out_of) * 100
|
|
||||||
|
|
||||||
submission = frappe.get_doc(
|
percentage = (score / score_out_of) * 100
|
||||||
|
result["answer"] = re.sub(
|
||||||
|
r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"]
|
||||||
|
)
|
||||||
|
|
||||||
|
submission = frappe.new_doc("LMS Quiz Submission")
|
||||||
|
# Score and percentage are calculated by the controller function
|
||||||
|
submission.update(
|
||||||
{
|
{
|
||||||
"doctype": "LMS Quiz Submission",
|
"doctype": "LMS Quiz Submission",
|
||||||
"quiz": quiz,
|
"quiz": quiz,
|
||||||
"result": results,
|
"result": results,
|
||||||
"score": score,
|
"score": 0,
|
||||||
"score_out_of": score_out_of,
|
"score_out_of": score_out_of,
|
||||||
"member": frappe.session.user,
|
"member": frappe.session.user,
|
||||||
"percentage": percentage,
|
"percentage": 0,
|
||||||
"passing_percentage": quiz_details.passing_percentage,
|
"passing_percentage": quiz_details.passing_percentage,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -139,128 +174,51 @@ def quiz_summary(quiz, results):
|
|||||||
"submission": submission.name,
|
"submission": submission.name,
|
||||||
"pass": percentage == quiz_details.passing_percentage,
|
"pass": percentage == quiz_details.passing_percentage,
|
||||||
"percentage": percentage,
|
"percentage": percentage,
|
||||||
|
"is_open_ended": is_open_ended,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
def _save_file(match):
|
||||||
def save_quiz(
|
data = match.group(1).split("data:")[1]
|
||||||
quiz_title,
|
headers, content = data.split(",")
|
||||||
passing_percentage,
|
mtype = headers.split(";", 1)[0]
|
||||||
questions,
|
|
||||||
max_attempts=0,
|
|
||||||
quiz=None,
|
|
||||||
show_answers=1,
|
|
||||||
show_submission_history=0,
|
|
||||||
):
|
|
||||||
if not has_course_moderator_role() or not has_course_instructor_role():
|
|
||||||
return
|
|
||||||
|
|
||||||
values = {
|
if isinstance(content, str):
|
||||||
"title": quiz_title,
|
content = content.encode("utf-8")
|
||||||
"passing_percentage": passing_percentage,
|
if b"," in content:
|
||||||
"max_attempts": max_attempts,
|
content = content.split(b",")[1]
|
||||||
"show_answers": show_answers,
|
|
||||||
"show_submission_history": show_submission_history,
|
try:
|
||||||
}
|
content = safe_b64decode(content)
|
||||||
|
except BinasciiError:
|
||||||
|
frappe.flags.has_dataurl = True
|
||||||
|
return f'<img src="#broken-image" alt="{get_corrupted_image_msg()}"'
|
||||||
|
|
||||||
|
if "filename=" in headers:
|
||||||
|
filename = headers.split("filename=")[-1]
|
||||||
|
filename = safe_decode(filename).split(";", 1)[0]
|
||||||
|
|
||||||
if quiz:
|
|
||||||
frappe.db.set_value("LMS Quiz", quiz, values)
|
|
||||||
update_questions(quiz, questions)
|
|
||||||
return quiz
|
|
||||||
else:
|
else:
|
||||||
doc = frappe.new_doc("LMS Quiz")
|
filename = get_random_filename(content_type=mtype)
|
||||||
doc.update(values)
|
|
||||||
doc.save()
|
|
||||||
update_questions(doc.name, questions)
|
|
||||||
return doc.name
|
|
||||||
|
|
||||||
|
_file = frappe.get_doc(
|
||||||
def update_questions(quiz, questions):
|
|
||||||
questions = json.loads(questions)
|
|
||||||
|
|
||||||
delete_questions(quiz, questions)
|
|
||||||
add_questions(quiz, questions)
|
|
||||||
frappe.db.set_value("LMS Quiz", quiz, "total_marks", set_total_marks(quiz, questions))
|
|
||||||
|
|
||||||
|
|
||||||
def delete_questions(quiz, questions):
|
|
||||||
existing_questions = frappe.get_all(
|
|
||||||
"LMS Quiz Question",
|
|
||||||
{
|
{
|
||||||
"parent": quiz,
|
"doctype": "File",
|
||||||
},
|
"file_name": filename,
|
||||||
pluck="name",
|
"content": content,
|
||||||
)
|
"decode": False,
|
||||||
|
"is_private": False,
|
||||||
current_questions = [question.get("question_name") for question in questions]
|
|
||||||
|
|
||||||
for question in existing_questions:
|
|
||||||
if question not in current_questions:
|
|
||||||
frappe.db.delete("LMS Quiz Question", question)
|
|
||||||
|
|
||||||
|
|
||||||
def add_questions(quiz, questions):
|
|
||||||
for index, question in enumerate(questions):
|
|
||||||
question = frappe._dict(question)
|
|
||||||
if question.question_name:
|
|
||||||
doc = frappe.get_doc("LMS Quiz Question", question.question_name)
|
|
||||||
else:
|
|
||||||
doc = frappe.new_doc("LMS Quiz Question")
|
|
||||||
doc.update(
|
|
||||||
{
|
|
||||||
"parent": quiz,
|
|
||||||
"parenttype": "LMS Quiz",
|
|
||||||
"parentfield": "questions",
|
|
||||||
"idx": index + 1,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
doc.update({"question": question.question, "marks": question.marks})
|
|
||||||
|
|
||||||
doc.save()
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def save_question(quiz, values, index):
|
|
||||||
values = frappe._dict(json.loads(values))
|
|
||||||
|
|
||||||
if values.get("name"):
|
|
||||||
doc = frappe.get_doc("LMS Question", values.get("name"))
|
|
||||||
else:
|
|
||||||
doc = frappe.new_doc("LMS Question")
|
|
||||||
|
|
||||||
doc.update(
|
|
||||||
{
|
|
||||||
"question": values.question,
|
|
||||||
"type": values["type"],
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
_file.save(ignore_permissions=True)
|
||||||
|
file_url = _file.unique_url
|
||||||
|
frappe.flags.has_dataurl = True
|
||||||
|
|
||||||
for num in range(1, 5):
|
return f'<img src="{file_url}"'
|
||||||
if values.get(f"option_{num}"):
|
|
||||||
doc.update(
|
|
||||||
{
|
|
||||||
f"option_{num}": values[f"option_{num}"],
|
|
||||||
f"is_correct_{num}": values[f"is_correct_{num}"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if values.get(f"explanation_{num}"):
|
|
||||||
doc.update(
|
|
||||||
{
|
|
||||||
f"explanation_{num}": values[f"explanation_{num}"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if values.get(f"possibility_{num}"):
|
def get_corrupted_image_msg():
|
||||||
doc.update(
|
return _("Image: Corrupted Data Stream")
|
||||||
{
|
|
||||||
f"possibility_{num}": values[f"possibility_{num}"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
doc.save()
|
|
||||||
return doc.name
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@@ -318,9 +276,3 @@ def check_input_answers(question, answer):
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_user_quizzes():
|
|
||||||
filters = {} if has_course_moderator_role() else {"owner": frappe.session.user}
|
|
||||||
return frappe.get_all("LMS Quiz", filters=filters, fields=["name", "title"])
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"column_break_qcpo",
|
"column_break_qcpo",
|
||||||
"marks",
|
"marks",
|
||||||
"section_break_huup",
|
"section_break_huup",
|
||||||
"question_detail"
|
"question_detail",
|
||||||
|
"type"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@@ -44,12 +45,21 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "section_break_huup",
|
"fieldname": "section_break_huup",
|
||||||
"fieldtype": "Section Break"
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "question.type",
|
||||||
|
"fieldname": "type",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Type",
|
||||||
|
"options": "Choices\nUser Input\nOpen Ended",
|
||||||
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-07-29 15:10:09.662715",
|
"modified": "2024-10-07 15:01:38.800906",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Quiz Question",
|
"name": "LMS Quiz Question",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"answer",
|
"answer",
|
||||||
"column_break_flus",
|
"column_break_flus",
|
||||||
"marks",
|
"marks",
|
||||||
|
"marks_out_of",
|
||||||
"is_correct"
|
"is_correct"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -33,8 +34,7 @@
|
|||||||
"fieldname": "is_correct",
|
"fieldname": "is_correct",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Is Correct",
|
"label": "Is Correct"
|
||||||
"read_only": 1
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "section_break_fztv",
|
"fieldname": "section_break_fztv",
|
||||||
@@ -54,14 +54,20 @@
|
|||||||
"fieldname": "marks",
|
"fieldname": "marks",
|
||||||
"fieldtype": "Int",
|
"fieldtype": "Int",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Marks",
|
"label": "Marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "marks_out_of",
|
||||||
|
"fieldtype": "Int",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Marks out of",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-05-17 17:38:51.760653",
|
"modified": "2024-10-07 17:28:38.597472",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Quiz Result",
|
"name": "LMS Quiz Result",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"quiz",
|
"quiz",
|
||||||
|
"quiz_title",
|
||||||
"course",
|
"course",
|
||||||
"column_break_3",
|
"column_break_3",
|
||||||
"member",
|
"member",
|
||||||
@@ -39,7 +40,6 @@
|
|||||||
"fieldtype": "Int",
|
"fieldtype": "Int",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Score",
|
"label": "Score",
|
||||||
"read_only": 1,
|
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -95,7 +95,6 @@
|
|||||||
"fieldtype": "Int",
|
"fieldtype": "Int",
|
||||||
"label": "Percentage",
|
"label": "Percentage",
|
||||||
"non_negative": 1,
|
"non_negative": 1,
|
||||||
"read_only": 1,
|
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -105,12 +104,19 @@
|
|||||||
"non_negative": 1,
|
"non_negative": 1,
|
||||||
"read_only": 1,
|
"read_only": 1,
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "quiz.title",
|
||||||
|
"fieldname": "quiz_title",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Quiz Title",
|
||||||
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"in_create": 1,
|
"in_create": 1,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-02-27 13:01:53.611726",
|
"modified": "2024-10-07 16:52:04.162521",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Quiz Submission",
|
"name": "LMS Quiz Submission",
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
# Copyright (c) 2021, FOSS United and contributors
|
# Copyright (c) 2021, FOSS United and contributors
|
||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
# import frappe
|
import frappe
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
from frappe.utils import cint
|
||||||
|
from frappe import _
|
||||||
|
|
||||||
|
|
||||||
class LMSQuizSubmission(Document):
|
class LMSQuizSubmission(Document):
|
||||||
def before_insert(self):
|
def validate(self):
|
||||||
if not self.percentage:
|
self.validate_marks()
|
||||||
self.set_percentage()
|
self.set_percentage()
|
||||||
|
|
||||||
|
def validate_marks(self):
|
||||||
|
for row in self.result:
|
||||||
|
if cint(row.marks) > cint(row.marks_out_of):
|
||||||
|
frappe.throw(
|
||||||
|
_(
|
||||||
|
"Marks for question number {0} cannot be greater than the marks allotted for that question."
|
||||||
|
).format(row.idx)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.score += cint(row.marks)
|
||||||
|
|
||||||
def set_percentage(self):
|
def set_percentage(self):
|
||||||
if self.score and self.score_out_of:
|
if self.score and self.score_out_of:
|
||||||
|
|||||||
@@ -2,6 +2,28 @@
|
|||||||
// For license information, please see license.txt
|
// For license information, please see license.txt
|
||||||
|
|
||||||
frappe.ui.form.on("LMS Settings", {
|
frappe.ui.form.on("LMS Settings", {
|
||||||
// refresh: function(frm) {
|
setup: function (frm) {
|
||||||
// }
|
frappe.call({
|
||||||
|
method: "lms.lms.doctype.lms_settings.lms_settings.check_payments_app",
|
||||||
|
callback: (data) => {
|
||||||
|
if (!data.message) {
|
||||||
|
frm.set_df_property("payment_section", "hidden", 1);
|
||||||
|
frm.trigger("set_no_payments_app_html");
|
||||||
|
} else {
|
||||||
|
frm.set_df_property("no_payments_app", "hidden", 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
set_no_payments_app_html(frm) {
|
||||||
|
frm.get_field("payments_app_is_not_installed").html(`
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
Please install the
|
||||||
|
<a target="_blank" style="color: var(--alert-text-warning); background: var(--alert-bg-warning);" href="https://frappecloud.com/marketplace/apps/payments">
|
||||||
|
Payments app
|
||||||
|
</a>
|
||||||
|
to enable payment gateway.
|
||||||
|
`);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,14 +42,15 @@
|
|||||||
"mentor_request_status_update",
|
"mentor_request_status_update",
|
||||||
"payment_settings_tab",
|
"payment_settings_tab",
|
||||||
"payment_section",
|
"payment_section",
|
||||||
"razorpay_key",
|
"payment_gateway",
|
||||||
"razorpay_secret",
|
|
||||||
"apply_gst",
|
|
||||||
"column_break_cfcv",
|
|
||||||
"default_currency",
|
"default_currency",
|
||||||
|
"exception_country",
|
||||||
|
"column_break_cfcv",
|
||||||
|
"apply_gst",
|
||||||
"show_usd_equivalent",
|
"show_usd_equivalent",
|
||||||
"apply_rounding",
|
"apply_rounding",
|
||||||
"exception_country",
|
"no_payments_app",
|
||||||
|
"payments_app_is_not_installed",
|
||||||
"email_templates_tab",
|
"email_templates_tab",
|
||||||
"certification_template",
|
"certification_template",
|
||||||
"batch_confirmation_template",
|
"batch_confirmation_template",
|
||||||
@@ -147,16 +148,6 @@
|
|||||||
"fieldname": "column_break_cfcv",
|
"fieldname": "column_break_cfcv",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"fieldname": "razorpay_key",
|
|
||||||
"fieldtype": "Data",
|
|
||||||
"label": "Razorpay Key"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "razorpay_secret",
|
|
||||||
"fieldtype": "Password",
|
|
||||||
"label": "Razorpay Secret"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
"fieldname": "apply_gst",
|
"fieldname": "apply_gst",
|
||||||
@@ -173,7 +164,7 @@
|
|||||||
"depends_on": "show_usd_equivalent",
|
"depends_on": "show_usd_equivalent",
|
||||||
"fieldname": "exception_country",
|
"fieldname": "exception_country",
|
||||||
"fieldtype": "Table MultiSelect",
|
"fieldtype": "Table MultiSelect",
|
||||||
"label": "Maintain Original Currency",
|
"label": "Primary Countries",
|
||||||
"options": "Payment Country"
|
"options": "Payment Country"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -331,12 +322,26 @@
|
|||||||
"fieldname": "custom_signup_content",
|
"fieldname": "custom_signup_content",
|
||||||
"fieldtype": "HTML Editor",
|
"fieldtype": "HTML Editor",
|
||||||
"label": "Custom Signup Content"
|
"label": "Custom Signup Content"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "payment_gateway",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Payment Gateway"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "no_payments_app",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "payments_app_is_not_installed",
|
||||||
|
"fieldtype": "HTML",
|
||||||
|
"label": "Payments app is not installed"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-09-23 17:57:01.350020",
|
"modified": "2024-10-01 12:15:49.800242",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Settings",
|
"name": "LMS Settings",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user