Compare commits

...

87 Commits

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

Otherwise if `row.progress == 10 or row.progress == 40 or row.progress == 70` wouldn't have an effect.
2024-10-29 19:28:14 +01:00
Jannat Patel
19b759e9fb feat: onboarding 2024-10-29 23:00:38 +05:30
Jannat Patel
df3bca6405 Merge pull request #1081 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-10-28 09:34:34 +05:30
Jannat Patel
5cde79b5eb chore: Persian translations 2024-10-27 17:10:17 +05:30
Jannat Patel
9b35cdbddc chore: Bosnian translations 2024-10-26 16:54:33 +05:30
Jannat Patel
70ec22004a chore: Persian translations 2024-10-26 16:54:32 +05:30
Jannat Patel
95ed77421a chore: Chinese Simplified translations 2024-10-26 16:54:31 +05:30
Jannat Patel
d64ec9817c chore: Turkish translations 2024-10-26 16:54:29 +05:30
Jannat Patel
ce01b7634f chore: Swedish translations 2024-10-26 16:54:28 +05:30
Jannat Patel
e0819f83bc chore: Russian translations 2024-10-26 16:54:27 +05:30
Jannat Patel
f87d28c2f5 chore: Polish translations 2024-10-26 16:54:25 +05:30
Jannat Patel
544b59744b chore: Hungarian translations 2024-10-26 16:54:24 +05:30
Jannat Patel
467dfb831d chore: German translations 2024-10-26 16:54:23 +05:30
Jannat Patel
4c4b4eaf55 chore: Arabic translations 2024-10-26 16:54:21 +05:30
Jannat Patel
227e5d00e5 chore: Spanish translations 2024-10-26 16:54:20 +05:30
Jannat Patel
73e9e384c8 chore: French translations 2024-10-26 16:54:18 +05:30
Jannat Patel
5bebdcba68 Merge pull request #1080 from frappe/l10n_develop2
chore: sync translations from crowdin
2024-10-25 16:48:48 +05:30
Jannat Patel
1c2e52ae4b chore: Esperanto translations 2024-10-25 16:35:56 +05:30
Jannat Patel
9377e89561 chore: Bosnian translations 2024-10-25 16:35:54 +05:30
Jannat Patel
4cae05ecbe chore: Persian translations 2024-10-25 16:35:53 +05:30
Jannat Patel
909dcfd51e chore: Chinese Simplified translations 2024-10-25 16:35:51 +05:30
Jannat Patel
2bd96a1f2a chore: Turkish translations 2024-10-25 16:35:49 +05:30
Jannat Patel
aca41080ee chore: Swedish translations 2024-10-25 16:35:48 +05:30
Jannat Patel
1c351696a9 chore: Russian translations 2024-10-25 16:35:46 +05:30
Jannat Patel
51a8958aa6 chore: Polish translations 2024-10-25 16:35:45 +05:30
Jannat Patel
777b8aed02 chore: Hungarian translations 2024-10-25 16:35:43 +05:30
Jannat Patel
3672b90075 chore: German translations 2024-10-25 16:35:42 +05:30
Jannat Patel
92c7e613db chore: Arabic translations 2024-10-25 16:35:40 +05:30
Jannat Patel
5c58b85a00 chore: Spanish translations 2024-10-25 16:35:39 +05:30
Jannat Patel
8af82daa37 chore: French translations 2024-10-25 16:35:36 +05:30
Jannat Patel
224bb18d3e Merge pull request #1077 from pateljannat/issues-45
fix: show live class start button only to moderators and evaluators
2024-10-23 12:53:42 +05:30
Jannat Patel
aab7bdcc20 fix: show live class start button only to moderators and evaluators 2024-10-23 11:02:16 +05:30
Jannat Patel
c5ca428d98 Merge pull request #1076 from pateljannat/issues-44
fix: misc issues
2024-10-23 10:55:42 +05:30
Frappe PR Bot
af0cc7126b chore(release): Bumped to Version 2.9.0 2024-10-23 05:09:14 +00:00
Jannat Patel
a085050d27 build: removed frappe-ui package 2024-10-23 10:36:26 +05:30
Jannat Patel
2442f35f56 fix: added is_instructor to jinja 2024-10-23 10:35:26 +05:30
Jannat Patel
ed79ea536b Merge pull request #1072 from frappe/pot_develop_2024-10-18
chore: update POT file
2024-10-18 23:06:49 +05:30
frappe-pr-bot
b3d0aecd14 chore: update POT file 2024-10-18 16:04:26 +00:00
Jannat Patel
5f43e67c0b Merge pull request #1068 from pateljannat/payment-issues
fix: batch enrollment after payment completion
2024-10-17 10:39:46 +05:30
Jannat Patel
49a765a9a6 style: fix spacing 2024-10-17 10:31:56 +05:30
Jannat Patel
4d82bc86e8 style: fix spacing 2024-10-17 10:30:06 +05:30
Jannat Patel
8fe02b83b8 fix: batch enrollment after payment completion 2024-10-17 09:27:24 +05:30
Jannat Patel
9c9075606b Merge pull request #1059 from frappe/pot_develop_2024-10-11
chore: update POT file
2024-10-15 19:38:24 +05:30
Jannat Patel
53285a0d19 fix: misc issues 2024-10-14 19:17:32 +05:30
Jannat Patel
9cdeaebb47 Merge pull request #1062 from pateljannat/quiz-timer
feat: timer in quiz
2024-10-14 16:11:55 +05:30
Jannat Patel
a9cb52c68b fix: hide timer instructions if duration is not set 2024-10-14 15:49:27 +05:30
Jannat Patel
f33e950e83 feat: timer in quiz 2024-10-14 14:31:26 +05:30
Jannat Patel
9c9b5963fe Merge pull request #1060 from pateljannat/issues-43
fix: redirect to login before enrollment
2024-10-11 22:33:52 +05:30
Jannat Patel
1597054cc9 fix: redirect to login before enrollment 2024-10-11 22:18:18 +05:30
frappe-pr-bot
deba6aa845 chore: update POT file 2024-10-11 16:04:13 +00:00
Jannat Patel
2d8ba3b84e Merge pull request #1058 from pateljannat/issues-42
fix: batch self enrollment
2024-10-11 19:22:50 +05:30
Jannat Patel
e56b28abad chore: removed unnecessary lines 2024-10-11 19:17:56 +05:30
Jannat Patel
eb350c5a20 fix: batch self enrollment 2024-10-11 19:16:40 +05:30
Jannat Patel
961d5ec77b Merge pull request #1057 from pateljannat/settings-minor-changes
fix: misc ux issues
2024-10-11 16:18:19 +05:30
Jannat Patel
fa566514aa fix: image fetch for settings 2024-10-11 15:32:41 +05:30
Jannat Patel
6e97449bf7 fix: misc ux issues 2024-10-11 13:39:30 +05:30
Jannat Patel
016dafb3c3 Merge pull request #1056 from pateljannat/issues-41
fix: misc issues
2024-10-10 16:43:59 +05:30
Jannat Patel
675bcc8956 test: replaced FrappeTestCase with UnitTestCase 2024-10-10 16:20:53 +05:30
Jannat Patel
aba4c034fc fix: misc issues 2024-10-10 14:48:59 +05:30
Jannat Patel
c76d8c582f Merge pull request #1052 from pateljannat/issues-40
fix: misc quiz issues
2024-10-09 19:17:01 +05:30
Jannat Patel
f1cb0e6f3c fix: usd conversion 2024-10-09 19:07:25 +05:30
Jannat Patel
d296687456 fix: misc quiz issues 2024-10-09 16:03:56 +05:30
Jannat Patel
5b68001c94 Merge pull request #1049 from pateljannat/issues-39
fix: create order for razorpay
2024-10-09 11:59:57 +05:30
Jannat Patel
8b1d9bb5a9 fix: create order for razorpay 2024-10-09 11:31:31 +05:30
92 changed files with 69256 additions and 5144 deletions

View File

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

View File

@@ -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").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

View File

@@ -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",

View File

@@ -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>

View File

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

View File

@@ -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

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="flex flex-col justify-between h-full"> <div class="flex flex-col justify-between min-h-0">
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="font-semibold mb-1"> <div class="font-semibold mb-1">
@@ -16,11 +16,13 @@
{{ __(description) }} {{ __(description) }}
</div> </div>
</div> </div>
<SettingFields :fields="fields" :data="data.data" /> <div class="overflow-y-auto">
<div class="flex flex-row-reverse mt-auto"> <SettingFields :fields="fields" :data="data.data" />
<Button variant="solid" :loading="saveSettings.loading" @click="update"> <div class="flex flex-row-reverse mt-auto">
{{ __('Update') }} <Button variant="solid" :loading="saveSettings.loading" @click="update">
</Button> {{ __('Update') }}
</Button>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -70,9 +72,16 @@ const update = () => {
fieldsToSave[f.name] = f.value fieldsToSave[f.name] = f.value
} }
}) })
saveSettings.submit({ saveSettings.submit(
fields: fieldsToSave, {
}) fields: fieldsToSave,
},
{
onSuccess(data) {
isDirty.value = false
},
}
)
} }
watch(props.data, (newData) => { watch(props.data, (newData) => {

View File

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

View File

@@ -2,6 +2,7 @@
<div> <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(() => {

View File

@@ -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: {

View File

@@ -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>

View File

@@ -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

View File

@@ -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()
},
},
],
}) })
} }

View File

@@ -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: {

View File

@@ -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>

View File

@@ -37,6 +37,7 @@
</div> </div>
<div class="flex items-center space-x-2 text-gray-900 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"
@@ -47,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') }}

View File

@@ -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,
})
}, },
} }
) )

View File

@@ -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),

View File

@@ -179,26 +179,6 @@ const tabsStructure = computed(() => {
name: 'app_name', name: 'app_name',
type: 'text', type: 'text',
}, },
{
label: 'Copyright',
name: 'copyright',
type: 'text',
},
{
label: 'Address',
name: 'address',
type: 'textarea',
rows: 4,
},
{
label: 'Footer "Powered By"',
name: 'footer_powered',
type: 'textarea',
rows: 4,
},
{
type: 'Column Break',
},
{ {
label: 'Logo', label: 'Logo',
name: 'banner_image', name: 'banner_image',
@@ -214,6 +194,23 @@ const tabsStructure = computed(() => {
name: 'footer_logo', name: 'footer_logo',
type: 'Upload', 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',
},
], ],
}, },
{ {
@@ -292,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.',
}, },
], ],
}, },

View File

@@ -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,7 +81,7 @@
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>
@@ -162,8 +180,8 @@
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>
<div class="flex items-center justify-between mt-5"> <div class="flex items-center justify-between mt-4">
<div> <div class="text-sm text-gray-600">
{{ {{
__('Question {0} of {1}').format( __('Question {0} of {1}').format(
activeQuestion, activeQuestion,
@@ -250,20 +268,29 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { Badge, Button, createResource, ListView, TextEditor } 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: {
@@ -284,6 +311,7 @@ const quiz = createResource({
auto: true, auto: true,
onSuccess(data) { onSuccess(data) {
populateQuestions() populateQuestions()
setupTimer()
}, },
}) })
@@ -299,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))
@@ -383,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) => {
@@ -493,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 = () => {
@@ -504,6 +570,7 @@ const resetQuiz = () => {
showAnswers.length = 0 showAnswers.length = 0
quizSubmission.reset() quizSubmission.reset()
populateQuestions() populateQuestions()
setupTimer()
} }
const getInstructions = (question) => { const getInstructions = (question) => {

View File

@@ -2,7 +2,7 @@
<div class="flex flex-col justify-between h-full"> <div class="flex flex-col justify-between h-full">
<div> <div>
<div class="flex itemsc-center justify-between"> <div class="flex itemsc-center justify-between">
<div class="font-semibold mb-1"> <div class="text-xl font-semibold leading-none mb-1">
{{ __(label) }} {{ __(label) }}
</div> </div>
<Badge <Badge

View File

@@ -17,17 +17,16 @@
/> />
<div v-else-if="field.type == 'Code'"> <div v-else-if="field.type == 'Code'">
<div> <CodeEditor
{{ __(field.label) }} :label="__(field.label)"
</div> type="HTML"
<Codemirror description="The HTML you add here will be shown on your sign up page."
v-model:value="data[field.name]" v-model="data[field.name]"
:height="200" height="250px"
:options="{ class="shrink-0"
mode: field.mode, :showLineNumbers="true"
theme: 'seti', >
}" </CodeEditor>
/>
</div> </div>
<div v-else-if="field.type == 'Upload'"> <div v-else-if="field.type == 'Upload'">
@@ -53,9 +52,11 @@
</template> </template>
</FileUploader> </FileUploader>
<div v-else> <div v-else>
<div class="flex items-center text-sm"> <div class="flex items-center text-sm space-x-2">
<div class="border rounded-md p-2 mr-2"> <div
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" /> 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>
<div class="flex flex-col flex-wrap"> <div class="flex flex-col flex-wrap">
<span class="break-all"> <span class="break-all">
@@ -73,6 +74,14 @@
</div> </div>
</div> </div>
<Switch
v-else-if="field.type == 'checkbox'"
size="sm"
:label="__(field.label)"
:description="__(field.description)"
v-model="data[field.name]"
/>
<FormControl <FormControl
v-else v-else
:key="field.name" :key="field.name"
@@ -88,14 +97,12 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { FormControl, FileUploader, Button } from 'frappe-ui' import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
import { computed } from 'vue' import { computed } from 'vue'
import { getFileSize, validateFile } from '@/utils' import { getFileSize, validateFile } from '@/utils'
import { X, FileText } from 'lucide-vue-next' import { X, FileText } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import Codemirror from 'codemirror-editor-vue3' import CodeEditor from '@/components/Controls/CodeEditor.vue'
import 'codemirror/theme/seti.css'
import 'codemirror/mode/htmlmixed/htmlmixed.js'
const props = defineProps({ const props = defineProps({
fields: { fields: {

View File

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

View File

@@ -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

View File

@@ -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)
) )
}) })

View File

@@ -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')

View File

@@ -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,
() => { () => {

View File

@@ -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">&middot;</span> <span v-if="course.data.rating" class="mx-3">&middot;</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
}) })

View File

@@ -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'

View File

@@ -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-46 md: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"
@@ -48,6 +48,7 @@
</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,
() => { () => {

View File

@@ -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,
}, },
} }

View File

@@ -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>

View File

@@ -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)
@@ -278,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()
} }
} }
@@ -300,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,
}, },
@@ -378,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}`
} }

View File

@@ -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,

View File

@@ -4,6 +4,19 @@
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<div class="space-x-2"> <div class="space-x-2">
<router-link
v-if="quizDetails.data?.name"
:to="{
name: 'QuizPage',
params: {
quizID: quizDetails.data.name,
},
}"
>
<Button>
{{ __('Open') }}
</Button>
</router-link>
<router-link <router-link
v-if="quizDetails.data?.name" v-if="quizDetails.data?.name"
:to="{ :to="{
@@ -25,7 +38,7 @@
<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
@@ -37,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')"
@@ -55,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">
@@ -73,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">
@@ -93,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()">
@@ -213,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,

View File

@@ -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,

View File

@@ -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>

View File

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

View File

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

View File

@@ -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": [],
} }

View File

@@ -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

View File

@@ -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()
@@ -293,7 +294,10 @@ def get_branding():
image_fields = ["banner_image", "footer_logo", "favicon"] image_fields = ["banner_image", "footer_logo", "favicon"]
for field in image_fields: for field in image_fields:
website_settings.update({field: get_file_info(website_settings.get(field))}) 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 return website_settings
@@ -322,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
@@ -576,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,
) )
@@ -754,3 +761,44 @@ def get_payment_gateway_details(payment_gateway):
"doctype": doctype, "doctype": doctype,
"docname": docname, "docname": docname,
} }
def update_course_statistics():
courses = frappe.get_all("LMS Course", fields=["name"])
for course in courses:
lessons = get_lesson_count(course.name)
enrollments = frappe.db.count(
"LMS Enrollment", {"course": course.name, "member_type": "Student"}
)
avg_rating = get_average_rating(course.name) or 0
avg_rating = flt(avg_rating, frappe.get_system_settings("float_precision") or 3)
frappe.db.set_value(
"LMS Course",
course.name,
{"lessons": lessons, "enrollments": enrollments, "rating": avg_rating},
)
@frappe.whitelist()
def get_announcements(batch):
return frappe.get_all(
"Communication",
filters={
"reference_doctype": "LMS Batch",
"reference_name": batch,
},
fields=[
"subject",
"content",
"recipients",
"cc",
"communication_date",
"sender",
"sender_full_name",
],
order_by="communication_date desc",
)

View File

@@ -7,17 +7,3 @@ from frappe.model.document import Document
class BatchStudent(Document): 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)

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -33,6 +33,7 @@ class LMSBatch(Document):
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:
@@ -139,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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -187,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,
},
)

View File

@@ -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",

View File

@@ -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",

View File

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

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -100,6 +100,16 @@ 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 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:
question_details = frappe.db.get_value( question_details = frappe.db.get_value(
@@ -113,17 +123,6 @@ def quiz_summary(quiz, results):
result["question"] = question_details.question_detail result["question"] = question_details.question_detail
result["marks_out_of"] = question_details.marks result["marks_out_of"] = question_details.marks
quiz_details = frappe.get_doc(
"LMS Quiz",
quiz,
["total_marks", "passing_percentage", "lesson", "course"],
as_dict=1,
)
score = 0
percentage = 0
score_out_of = quiz_details.total_marks
if question_details.type != "Open Ended": if question_details.type != "Open Ended":
correct = result["is_correct"][0] correct = result["is_correct"][0]
for point in result["is_correct"]: for point in result["is_correct"]:
@@ -135,24 +134,26 @@ def quiz_summary(quiz, results):
score += marks score += marks
del result["question_name"] del result["question_name"]
percentage = (score / score_out_of) * 100
else: else:
result["is_correct"] = 0 result["is_correct"] = 0
is_open_ended = True is_open_ended = True
percentage = (score / score_out_of) * 100
result["answer"] = re.sub( result["answer"] = re.sub(
r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"] r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"]
) )
submission = frappe.get_doc( 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,
} }
) )

View File

@@ -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 TestLMSSidebarItem(FrappeTestCase): class TestLMSSidebarItem(UnitTestCase):
pass pass

View File

@@ -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 TestLMSSource(FrappeTestCase): class TestLMSSource(UnitTestCase):
pass pass

View File

@@ -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 TestLMSTimetableLegend(FrappeTestCase): class TestLMSTimetableLegend(UnitTestCase):
pass pass

View File

@@ -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 TestLMSTimetableTemplate(FrappeTestCase): class TestLMSTimetableTemplate(UnitTestCase):
pass pass

View File

@@ -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 TestPaymentCountry(FrappeTestCase): class TestPaymentCountry(UnitTestCase):
pass pass

View File

@@ -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 TestZoomSettings(FrappeTestCase): class TestZoomSettings(UnitTestCase):
pass pass

View File

@@ -42,6 +42,10 @@ def get_payment_link(doctype, docname, title, amount, total_amount, currency, ad
"redirect_to": redirect_to, "redirect_to": redirect_to,
"payment": payment.name, "payment": payment.name,
} }
if payment_gateway == "Razorpay":
order = controller.create_order(**payment_details)
payment_details.update({"order_id": order.get("id")})
url = controller.get_payment_url(**payment_details) url = controller.get_payment_url(**payment_details)
return url return url

View File

@@ -86,32 +86,32 @@ def get_charts(data):
completed = 0 completed = 0
less_than_hundred = 0 less_than_hundred = 0
less_than_seventy = 0 less_than_seventy_one = 0
less_than_forty = 0 less_than_forty_one = 0
less_than_ten = 0 less_than_eleven = 0
for row in data: for row in data:
if row.progress == 100: if row.progress == 100:
completed += 1 completed += 1
elif row.progress < 100 and row.progress > 70: elif row.progress < 100 and row.progress > 70:
less_than_hundred += 1 less_than_hundred += 1
elif row.progress < 70 and row.progress > 40: elif row.progress < 71 and row.progress > 40:
less_than_seventy += 1 less_than_seventy_one += 1
elif row.progress < 40 and row.progress > 10: elif row.progress < 41 and row.progress > 10:
less_than_forty += 1 less_than_forty_one += 1
elif row.progress < 10: elif row.progress < 11:
less_than_ten += 1 less_than_eleven += 1
charts = { charts = {
"data": { "data": {
"labels": ["0-10", "10-40", "40-70", "70-99", "100"], "labels": ["0-10", "11-40", "41-70", "71-99", "100"],
"datasets": [ "datasets": [
{ {
"name": "Progress (%)", "name": "Progress (%)",
"values": [ "values": [
less_than_ten, less_than_eleven,
less_than_forty, less_than_forty_one,
less_than_seventy, less_than_seventy_one,
less_than_hundred, less_than_hundred,
completed, completed,
], ],

View File

@@ -17,6 +17,7 @@ from frappe.utils import (
add_months, add_months,
cint, cint,
cstr, cstr,
ceil,
flt, flt,
fmt_money, fmt_money,
format_date, format_date,
@@ -108,7 +109,7 @@ def get_chapters(course):
chapter_details = frappe.db.get_value( chapter_details = frappe.db.get_value(
"Course Chapter", "Course Chapter",
{"name": chapter.chapter}, {"name": chapter.chapter},
["name", "title", "description"], ["name", "title"],
as_dict=True, as_dict=True,
) )
chapter.update(chapter_details) chapter.update(chapter_details)
@@ -156,11 +157,12 @@ def get_lesson_details(chapter, progress=False):
"file_type", "file_type",
"instructor_notes", "instructor_notes",
"course", "course",
"content",
], ],
as_dict=True, as_dict=True,
) )
lesson_details.number = f"{chapter.idx}.{row.idx}" lesson_details.number = f"{chapter.idx}.{row.idx}"
lesson_details.icon = get_lesson_icon(lesson_details.body) lesson_details.icon = get_lesson_icon(lesson_details.body, lesson_details.content)
if progress: if progress:
lesson_details.is_complete = get_progress(lesson_details.course, lesson_details.name) lesson_details.is_complete = get_progress(lesson_details.course, lesson_details.name)
@@ -169,20 +171,38 @@ def get_lesson_details(chapter, progress=False):
return lessons return lessons
def get_lesson_icon(content): def get_lesson_icon(body, content):
icon = None if content:
macros = find_macros(content) content = json.loads(content)
for block in content.get("blocks"):
if block.get("type") == "upload" and block.get("data").get("file_type").lower() in [
"mp4",
"webm",
"ogg",
"mov",
]:
return "icon-youtube"
if block.get("type") == "embed" and block.get("data").get("service") in [
"youtube",
"vimeo",
]:
return "icon-youtube"
if block.get("type") == "quiz":
return "icon-quiz"
return "icon-list"
macros = find_macros(body)
for macro in macros: for macro in macros:
if macro[0] == "YouTubeVideo" or macro[0] == "Video": if macro[0] == "YouTubeVideo" or macro[0] == "Video":
icon = "icon-youtube" return "icon-youtube"
elif macro[0] == "Quiz": elif macro[0] == "Quiz":
icon = "icon-quiz" return "icon-quiz"
if not icon: return "icon-list"
icon = "icon-list"
return icon
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@@ -948,7 +968,7 @@ def check_multicurrency(amount, currency, country=None, amount_usd=None):
if apply_rounding and amount % 100 != 0: if apply_rounding and amount % 100 != 0:
amount = amount + 100 - amount % 100 amount = amount + 100 - amount % 100
return amount, currency return ceil(amount), currency
def apply_gst(amount, country=None): def apply_gst(amount, country=None):
@@ -1026,23 +1046,13 @@ def get_course_details(course):
"currency", "currency",
"amount_usd", "amount_usd",
"enable_certification", "enable_certification",
"lessons",
"enrollments",
"rating",
], ],
as_dict=1, as_dict=1,
) )
course_details.tags = course_details.tags.split(",") if course_details.tags else [] course_details.tags = course_details.tags.split(",") if course_details.tags else []
course_details.lesson_count = get_lesson_count(course_details.name)
course_details.enrollment_count = frappe.db.count(
"LMS Enrollment", {"course": course_details.name, "member_type": "Student"}
)
course_details.enrollment_count_formatted = format_number(
course_details.enrollment_count
)
avg_rating = get_average_rating(course_details.name) or 0
course_details.avg_rating = flt(
avg_rating, frappe.get_system_settings("float_precision") or 3
)
course_details.instructors = get_instructors(course_details.name) course_details.instructors = get_instructors(course_details.name)
if course_details.paid_course: if course_details.paid_course:
@@ -1091,14 +1101,14 @@ def get_categorized_courses(courses):
): ):
new.append(course) new.append(course)
if course.membership and course.published: if course.membership:
enrolled.append(course) enrolled.append(course)
elif course.is_instructor: elif course.is_instructor:
created.append(course) created.append(course)
categories = [live, enrolled, created] categories = [live, enrolled, created]
for category in categories: for category in categories:
category.sort(key=lambda x: x.enrollment_count, reverse=True) category.sort(key=lambda x: x.enrollments, reverse=True)
live.sort(key=lambda x: x.featured, reverse=True) live.sort(key=lambda x: x.featured, reverse=True)
@@ -1123,7 +1133,7 @@ def get_course_outline(course, progress=False):
chapter_details = frappe.db.get_value( chapter_details = frappe.db.get_value(
"Course Chapter", "Course Chapter",
chapter.chapter, chapter.chapter,
["name", "title", "description"], ["name", "title"],
as_dict=True, as_dict=True,
) )
chapter_details["idx"] = chapter.idx chapter_details["idx"] = chapter.idx
@@ -1677,7 +1687,7 @@ def update_payment_record(doctype, docname):
if doctype == "LMS Course": if doctype == "LMS Course":
enroll_in_course(data.payment, docname) enroll_in_course(data.payment, docname)
else: else:
enroll_in_batch(data.payment, docname) enroll_in_batch(docname, data.payment)
except Exception as e: except Exception as e:
frappe.log_error(frappe.get_traceback(), _("Enrollment Failed")) frappe.log_error(frappe.get_traceback(), _("Enrollment Failed"))
@@ -1701,25 +1711,33 @@ def enroll_in_course(payment_name, course):
enrollment.save(ignore_permissions=True) enrollment.save(ignore_permissions=True)
def enroll_in_batch(payment_name, batch): @frappe.whitelist()
def enroll_in_batch(batch, payment_name=None):
if not frappe.db.exists( if not frappe.db.exists(
"Batch Student", {"parent": batch, "student": frappe.session.user} "Batch Student", {"parent": batch, "student": frappe.session.user}
): ):
student = frappe.new_doc("Batch Student") student = frappe.new_doc("Batch Student")
current_count = frappe.db.count("Batch Student", {"parent": batch}) current_count = frappe.db.count("Batch Student", {"parent": batch})
payment = frappe.db.get_value(
"LMS Payment", payment_name, ["name", "source"], as_dict=True
)
student.update( student.update(
{ {
"student": frappe.session.user, "student": frappe.session.user,
"payment": payment.name,
"source": payment.source,
"parent": batch, "parent": batch,
"parenttype": "LMS Batch", "parenttype": "LMS Batch",
"parentfield": "students", "parentfield": "students",
"idx": current_count + 1, "idx": current_count + 1,
} }
) )
if payment_name:
payment = frappe.db.get_value(
"LMS Payment", payment_name, ["name", "source"], as_dict=True
)
student.update(
{
"payment": payment.name,
"source": payment.source,
}
)
student.save(ignore_permissions=True) student.save(ignore_permissions=True)

5361
lms/locale/ar.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/bs.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/de.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/eo.po Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

5361
lms/locale/fa.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/fr.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/hu.po Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

5361
lms/locale/pl.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/ru.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/sv.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/tr.po Normal file

File diff suppressed because it is too large Load Diff

5361
lms/locale/zh.po Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -90,4 +90,5 @@ lms.patches.v1_0.set_published_on
lms.patches.v2_0.fix_progress_percentage lms.patches.v2_0.fix_progress_percentage
lms.patches.v2_0.add_discussion_topic_titles lms.patches.v2_0.add_discussion_topic_titles
lms.patches.v2_0.sidebar_settings lms.patches.v2_0.sidebar_settings
lms.patches.v2_0.delete_certificate_request_notification #18-09-2024 lms.patches.v2_0.delete_certificate_request_notification #18-09-2024
lms.patches.v2_0.add_course_statistics #21-10-2024

View File

@@ -0,0 +1,6 @@
import frappe
from lms.lms.api import update_course_statistics
def execute():
update_course_statistics()