mirror of
https://github.com/frappe/lms.git
synced 2026-05-02 13:39:31 +03:00
Merge branch 'develop' into develop
This commit is contained in:
Vendored
+3
-1
@@ -6,5 +6,7 @@
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
const LucideGithub: typeof import('~icons/lucide/github').default
|
||||
const LucideLinkedin: typeof import('~icons/lucide/linkedin').default
|
||||
const LucideTwitter: typeof import('~icons/lucide/twitter').default
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"serve": "vite preview",
|
||||
"build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry && yarn copy-colors-json",
|
||||
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html",
|
||||
"copy-colors-json": "cp node_modules/frappe-ui/src/tailwind/colors.json src/utils/frappe-ui-colors.json"
|
||||
"copy-colors-json": "cp node_modules/frappe-ui/tailwind/colors.json src/utils/frappe-ui-colors.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "6.4.9",
|
||||
@@ -34,7 +34,7 @@
|
||||
"dayjs": "1.11.10",
|
||||
"dompurify": "3.2.6",
|
||||
"feather-icons": "4.28.0",
|
||||
"frappe-ui": "0.1.227",
|
||||
"frappe-ui": "^0.1.254",
|
||||
"highlight.js": "11.11.1",
|
||||
"lucide-vue-next": "0.383.0",
|
||||
"markdown-it": "14.0.0",
|
||||
@@ -59,8 +59,5 @@
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"vite": "5.0.11",
|
||||
"vite-plugin-pwa": "0.15.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"@iconify/utils": "2.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="font-semibold text-ink-gray-9">
|
||||
|
||||
@@ -2,18 +2,21 @@
|
||||
<div class="mb-4">
|
||||
<div v-if="label" class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ __(label) }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
<span v-if="required" class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!modelValue"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file: File) => saveImage(file)"
|
||||
:fileTypes="[fileType]"
|
||||
:validateFile="(file: File) => validateFile(file, true, type)"
|
||||
@success="(file: File) => saveFile(file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md w-fit py-7 px-20">
|
||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||
<component
|
||||
:is="props.type === 'image' ? Image : Video"
|
||||
class="size-5 stroke-1 text-ink-gray-7"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
@@ -28,7 +31,15 @@
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img :src="modelValue" class="border rounded-md w-44 h-auto" />
|
||||
<img
|
||||
v-if="type == 'image'"
|
||||
:src="modelValue"
|
||||
class="border rounded-md w-44 h-auto"
|
||||
/>
|
||||
<video v-else controls class="border rounded-md w-44 h-auto">
|
||||
<source :src="modelValue" />
|
||||
{{ __('Your browser does not support the video tag.') }}
|
||||
</video>
|
||||
<div class="ml-4">
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
@@ -47,7 +58,8 @@
|
||||
<script setup lang="ts">
|
||||
import { validateFile } from '@/utils'
|
||||
import { Button, FileUploader } from 'frappe-ui'
|
||||
import { Image } from 'lucide-vue-next'
|
||||
import { Image, Video } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
@@ -58,15 +70,23 @@ const props = withDefaults(
|
||||
modelValue: string
|
||||
label?: string
|
||||
description?: string
|
||||
type: 'image' | 'video'
|
||||
required?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
label: '',
|
||||
description: '',
|
||||
type: 'image',
|
||||
required: true,
|
||||
}
|
||||
)
|
||||
|
||||
const saveImage = (file: any) => {
|
||||
const fileType = computed(() => {
|
||||
return props.type === 'image' ? 'image/*' : 'video/*'
|
||||
})
|
||||
|
||||
const saveFile = (file: any) => {
|
||||
emit('update:modelValue', file.file_url)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto">
|
||||
<SettingFields :fields="fields" :data="branding.data" />
|
||||
<SettingFields :sections="sections" :data="branding.data" />
|
||||
</div>
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||
@@ -34,7 +34,7 @@ import { watch, ref } from 'vue'
|
||||
const isDirty = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
sections: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
@@ -65,23 +65,9 @@ const saveSettings = createResource({
|
||||
})
|
||||
|
||||
const update = () => {
|
||||
let fieldsToSave = {}
|
||||
let imageFields = ['favicon', 'banner_image']
|
||||
props.fields.forEach((f) => {
|
||||
if (imageFields.includes(f.name)) {
|
||||
fieldsToSave[f.name] =
|
||||
branding.data[f.name] && branding.data[f.name].file_url
|
||||
? branding.data[f.name].file_url
|
||||
: null
|
||||
} else {
|
||||
fieldsToSave[f.name] = branding.data[f.name]
|
||||
}
|
||||
})
|
||||
|
||||
fieldsToSave['app_logo'] = fieldsToSave['banner_image']
|
||||
saveSettings.submit(
|
||||
{
|
||||
fields: fieldsToSave,
|
||||
fields: getFieldsToSave(),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
@@ -91,18 +77,36 @@ const update = () => {
|
||||
)
|
||||
}
|
||||
|
||||
watch(branding, (updatedDoc) => {
|
||||
let textFields = []
|
||||
let imageFields = []
|
||||
const getFieldsToSave = () => {
|
||||
let imageFields = ['favicon', 'banner_image']
|
||||
let fieldsToSave = {}
|
||||
|
||||
props.fields.forEach((f) => {
|
||||
if (f.type === 'Upload') {
|
||||
imageFields.push(f.name)
|
||||
} else {
|
||||
textFields.push(f.name)
|
||||
}
|
||||
props.sections.forEach((section) => {
|
||||
section.columns.forEach((column) => {
|
||||
column.fields.forEach((field) => {
|
||||
if (imageFields.includes(field.name)) {
|
||||
fieldsToSave[field.name] =
|
||||
branding.data[field.name] && branding.data[field.name].file_url
|
||||
? branding.data[field.name].file_url
|
||||
: null
|
||||
} else {
|
||||
fieldsToSave[field.name] = branding.data[field.name]
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
fieldsToSave['app_logo'] = fieldsToSave['banner_image']
|
||||
return fieldsToSave
|
||||
}
|
||||
|
||||
watch(branding, (updatedDoc) => {
|
||||
updateDirtyState(updatedDoc)
|
||||
})
|
||||
|
||||
const updateDirtyState = (updatedDoc) => {
|
||||
const { textFields, imageFields } = segregateFields()
|
||||
|
||||
textFields.forEach((field) => {
|
||||
if (updatedDoc.data[field] != updatedDoc.previousData[field]) {
|
||||
isDirty.value = true
|
||||
@@ -111,11 +115,29 @@ watch(branding, (updatedDoc) => {
|
||||
|
||||
imageFields.forEach((field) => {
|
||||
if (
|
||||
updatedDoc.data[field] &&
|
||||
updatedDoc.data[field].file_url != updatedDoc.previousData[field].file_url
|
||||
updatedDoc.data[field]?.file_url !=
|
||||
updatedDoc.previousData[field]?.file_url
|
||||
) {
|
||||
isDirty.value = true
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const segregateFields = () => {
|
||||
let textFields = []
|
||||
let imageFields = []
|
||||
|
||||
props.sections.forEach((section) => {
|
||||
section.columns.forEach((column) => {
|
||||
column.fields.forEach((field) => {
|
||||
if (field.type === 'Upload') {
|
||||
imageFields.push(field.name)
|
||||
} else {
|
||||
textFields.push(field.name)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
return { textFields, imageFields }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
<template>
|
||||
<div class="flex flex-col justify-between h-full text-base">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xl font-semibold leading-none mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
<div class="flex flex-col h-full text-base overflow-y-hidden">
|
||||
<div class="">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="text-xl font-semibold leading-none text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="data.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</div>
|
||||
<Badge
|
||||
v-if="data.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
<Button variant="solid" :loading="data.save.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingFields :fields="fields" :data="data.doc" />
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" :loading="data.save.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
<SettingFields :sections="sections" :data="data.doc" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +31,7 @@ import { Button, Badge, toast } from 'frappe-ui'
|
||||
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
sections: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
@@ -49,13 +49,6 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const update = () => {
|
||||
props.fields.forEach((f) => {
|
||||
if (f.type == 'Upload') {
|
||||
props.data.doc[f.name] = f.value ? f.value.file_url : null
|
||||
} else if (f.type != 'Column Break') {
|
||||
props.data.doc[f.name] = f.value
|
||||
}
|
||||
})
|
||||
props.data.save.submit(
|
||||
{},
|
||||
{
|
||||
|
||||
@@ -1,115 +1,123 @@
|
||||
<template>
|
||||
<div
|
||||
class="my-5"
|
||||
:class="{ 'flex justify-between w-full': columns.length > 1 }"
|
||||
>
|
||||
<div v-for="(column, index) in columns" :key="index">
|
||||
<div class="mb-5 divide-y overflow-y-auto">
|
||||
<div v-for="(section, index) in sections" class="py-5">
|
||||
<div v-if="section.label" class="font-semibold text-ink-gray-9 mb-4">
|
||||
{{ section.label }}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col space-y-5"
|
||||
:class="columns.length > 1 ? 'w-[21rem]' : 'w-full'"
|
||||
:class="{
|
||||
'flex justify-between space-x-8 w-full': section.columns.length > 1,
|
||||
}"
|
||||
>
|
||||
<div v-for="field in column">
|
||||
<Link
|
||||
v-if="field.type == 'Link'"
|
||||
v-model="data[field.name]"
|
||||
:doctype="field.doctype"
|
||||
:label="__(field.label)"
|
||||
:description="__(field.description)"
|
||||
/>
|
||||
|
||||
<div v-else-if="field.type == 'Code'">
|
||||
<CodeEditor
|
||||
:label="__(field.label)"
|
||||
type="HTML"
|
||||
description="The HTML you add here will be shown on your sign up page."
|
||||
<div
|
||||
v-for="(column, index) in section.columns"
|
||||
class="w-full space-y-5"
|
||||
>
|
||||
<div v-for="field in column.fields">
|
||||
<Link
|
||||
v-if="field.type == 'Link'"
|
||||
v-model="data[field.name]"
|
||||
height="250px"
|
||||
class="shrink-0"
|
||||
:showLineNumbers="true"
|
||||
>
|
||||
</CodeEditor>
|
||||
</div>
|
||||
:doctype="field.doctype"
|
||||
:label="__(field.label)"
|
||||
:description="__(field.description)"
|
||||
/>
|
||||
|
||||
<div v-else-if="field.type == 'Upload'">
|
||||
<div class="space-y-1 mb-2">
|
||||
<div class="text-sm text-ink-gray-5 font-medium">
|
||||
{{ __(field.label) }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5 leading-5">
|
||||
{{ __(field.description) }}
|
||||
</div>
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!data[field.name]"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => (data[field.name] = file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
<div v-else-if="field.type == 'Code'">
|
||||
<CodeEditor
|
||||
:label="__(field.label)"
|
||||
type="HTML"
|
||||
description="The HTML you add here will be shown on your sign up page."
|
||||
v-model="data[field.name]"
|
||||
height="250px"
|
||||
class="shrink-0"
|
||||
:showLineNumbers="true"
|
||||
>
|
||||
<div class="">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading ? `Uploading ${progress}%` : 'Upload an image'
|
||||
}}
|
||||
</Button>
|
||||
</CodeEditor>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.type == 'Upload'">
|
||||
<div class="space-y-1 mb-2">
|
||||
<div class="text-sm text-ink-gray-9 font-medium">
|
||||
{{ __(field.label) }}
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else>
|
||||
<div class="flex items-center text-sm space-x-2">
|
||||
<div
|
||||
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
|
||||
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
|
||||
<div class="text-sm text-ink-gray-5 leading-5">
|
||||
{{ __(field.description) }}
|
||||
</div>
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!data[field.name]"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => (data[field.name] = file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<img
|
||||
:src="data[field.name]?.file_url || data[field.name]"
|
||||
class="rounded"
|
||||
:class="field.size == 'lg' ? 'w-36' : 'size-6'"
|
||||
<div class="">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading ? `Uploading ${progress}%` : 'Upload an image'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else>
|
||||
<div class="flex items-center text-sm space-x-2">
|
||||
<div
|
||||
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
|
||||
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
|
||||
>
|
||||
<img
|
||||
:src="data[field.name]?.file_url || data[field.name]"
|
||||
class="rounded"
|
||||
:class="field.size == 'lg' ? 'w-36' : 'size-6'"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col flex-wrap">
|
||||
<span class="break-all text-ink-gray-9">
|
||||
{{
|
||||
data[field.name]?.file_name ||
|
||||
data[field.name].split('/').pop()
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="data[field.name]?.file_size"
|
||||
class="text-sm text-ink-gray-5 mt-1"
|
||||
>
|
||||
{{ getFileSize(data[field.name]?.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="data[field.name] = null"
|
||||
class="border text-ink-gray-7 border-outline-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col flex-wrap">
|
||||
<span class="break-all text-ink-gray-9">
|
||||
{{
|
||||
data[field.name]?.file_name ||
|
||||
data[field.name].split('/').pop()
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="data[field.name]?.file_size"
|
||||
class="text-sm text-ink-gray-5 mt-1"
|
||||
>
|
||||
{{ getFileSize(data[field.name]?.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="data[field.name] = null"
|
||||
class="border text-ink-gray-7 border-outline-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
v-else-if="field.type == 'checkbox'"
|
||||
size="sm"
|
||||
:label="__(field.label)"
|
||||
:description="__(field.description)"
|
||||
v-model="field.value"
|
||||
/>
|
||||
<!-- <div v-else>
|
||||
{{ data[field.name] }}
|
||||
|
||||
</div> -->
|
||||
<FormControl
|
||||
v-else
|
||||
:key="field.name"
|
||||
v-model="data[field.name]"
|
||||
:label="__(field.label)"
|
||||
:type="field.type"
|
||||
:rows="field.rows"
|
||||
:options="field.options"
|
||||
:description="field.description"
|
||||
placeholder=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
v-else-if="field.type == 'checkbox'"
|
||||
size="sm"
|
||||
:label="__(field.label)"
|
||||
:description="__(field.description)"
|
||||
v-model="field.value"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-else
|
||||
:key="field.name"
|
||||
v-model="data[field.name]"
|
||||
:label="__(field.label)"
|
||||
:type="field.type"
|
||||
:rows="field.rows"
|
||||
:options="field.options"
|
||||
:description="field.description"
|
||||
:class="columns.length > 1 ? 'w-full' : 'w-1/2'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,14 +125,14 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { getFileSize, validateFile } from '@/utils'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import CodeEditor from '@/components/Controls/CodeEditor.vue'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
sections: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
@@ -134,30 +142,34 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const columns = computed(() => {
|
||||
const cols = []
|
||||
let currentColumn = []
|
||||
|
||||
props.fields.forEach((field) => {
|
||||
if (field.type === 'Column Break') {
|
||||
if (currentColumn.length > 0) {
|
||||
cols.push(currentColumn)
|
||||
currentColumn = []
|
||||
}
|
||||
} else {
|
||||
if (field.type == 'checkbox') {
|
||||
field.value = props.data[field.name] ? true : false
|
||||
} else {
|
||||
field.value = props.data[field.name]
|
||||
}
|
||||
currentColumn.push(field)
|
||||
}
|
||||
onMounted(() => {
|
||||
props.sections.forEach((section) => {
|
||||
section.columns.forEach((column) => {
|
||||
column.fields.forEach((field) => {
|
||||
if (field.type == 'checkbox') {
|
||||
field.value = props.data[field.name] ? true : false
|
||||
} else {
|
||||
field.value = props.data[field.name]
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if (currentColumn.length > 0) {
|
||||
cols.push(currentColumn)
|
||||
}
|
||||
|
||||
return cols
|
||||
})
|
||||
|
||||
watch(
|
||||
props.sections,
|
||||
(newSections) => {
|
||||
// Makes the form dirty on change
|
||||
newSections.forEach((section) => {
|
||||
section.columns.forEach((column) => {
|
||||
column.fields.forEach((field) => {
|
||||
if (props.data[field.name] != field.value) {
|
||||
props.data[field.name] = field.value
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div v-for="tab in tabs" :key="tab.label">
|
||||
<div
|
||||
v-if="!tab.hideLabel"
|
||||
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-ink-gray-5 transition-all duration-300 ease-in-out"
|
||||
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base text-ink-gray-5 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<span>{{ __(tab.label) }}</span>
|
||||
</div>
|
||||
@@ -38,7 +38,7 @@
|
||||
label: activeTab.label,
|
||||
description: activeTab.description,
|
||||
...(activeTab.label == 'Branding'
|
||||
? { fields: activeTab.fields }
|
||||
? { sections: activeTab.sections }
|
||||
: {}),
|
||||
...(activeTab.label == 'Evaluators' ||
|
||||
activeTab.label == 'Members' ||
|
||||
@@ -47,16 +47,9 @@
|
||||
: {}),
|
||||
}"
|
||||
/>
|
||||
<!-- <PaymentSettings
|
||||
v-else-if="activeTab.label === 'Gateways'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
:data="data"
|
||||
:fields="activeTab.fields"
|
||||
/> -->
|
||||
<SettingDetails
|
||||
v-else
|
||||
:fields="activeTab.fields"
|
||||
:sections="activeTab.sections"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
:data="data"
|
||||
@@ -99,90 +92,158 @@ const data = createDocumentResource({
|
||||
const tabsStructure = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Settings',
|
||||
label: 'Configuration',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'General',
|
||||
icon: 'Wrench',
|
||||
fields: [
|
||||
sections: [
|
||||
{
|
||||
label: 'Allow Guest Access',
|
||||
name: 'allow_guest_access',
|
||||
description:
|
||||
'If enabled, users can access the course and batch lists without logging in.',
|
||||
type: 'checkbox',
|
||||
label: 'System Configurations',
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Allow Guest Access',
|
||||
name: 'allow_guest_access',
|
||||
description:
|
||||
'If enabled, users can access the course and batch lists without logging in.',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Prevent Skipping Videos',
|
||||
name: 'prevent_skipping_videos',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, users will no able to move forward in a video',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Disable PWA',
|
||||
name: 'disable_pwa',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If checked, users will not be able to install the application as a Progressive Web App.',
|
||||
},
|
||||
{
|
||||
label: 'Send calendar invite for evaluations',
|
||||
name: 'send_calendar_invite_for_evaluations',
|
||||
description:
|
||||
'If enabled, it sends google calendar invite to the student for evaluations.',
|
||||
type: 'checkbox',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Prevent Skipping Videos',
|
||||
name: 'prevent_skipping_videos',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, users will no able to move forward in a video',
|
||||
label: 'Notifications',
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Send Notification for Published Courses',
|
||||
name: 'send_notification_for_published_courses',
|
||||
type: 'select',
|
||||
options: [' ', 'Email', 'In-app'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Send Notification for Published Batches',
|
||||
name: 'send_notification_for_published_batches',
|
||||
type: 'select',
|
||||
options: [' ', 'Email', 'In-app'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Disable PWA',
|
||||
name: 'disable_pwa',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If checked, users will not be able to install the application as a Progressive Web App.',
|
||||
label: 'Email Templates',
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Batch Confirmation Email Template',
|
||||
name: 'batch_confirmation_template',
|
||||
doctype: 'Email Template',
|
||||
type: 'Link',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Certification Email Template',
|
||||
name: 'certification_template',
|
||||
doctype: 'Email Template',
|
||||
type: 'Link',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Send calendar invite for evaluations',
|
||||
name: 'send_calendar_invite_for_evaluations',
|
||||
description:
|
||||
'If enabled, it sends google calendar invite to the student for evaluations.',
|
||||
type: 'checkbox',
|
||||
label: 'Contact Information',
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Email',
|
||||
name: 'contact_us_email',
|
||||
type: 'text',
|
||||
description:
|
||||
'Users can reach out to this email for support or inquiries.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'URL',
|
||||
name: 'contact_us_url',
|
||||
type: 'text',
|
||||
description:
|
||||
'Users can reach out to this URL for support or inquiries.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'Column Break',
|
||||
},
|
||||
{
|
||||
label: 'Livecode URL',
|
||||
name: 'livecode_url',
|
||||
doctype: 'Livecode URL',
|
||||
type: 'text',
|
||||
description:
|
||||
'https://docs.frappe.io/learning/falcon-self-hosting-guide',
|
||||
},
|
||||
{
|
||||
label: 'Batch Confirmation Email Template',
|
||||
name: 'batch_confirmation_template',
|
||||
doctype: 'Email Template',
|
||||
type: 'Link',
|
||||
},
|
||||
{
|
||||
label: 'Certification Email Template',
|
||||
name: 'certification_template',
|
||||
doctype: 'Email Template',
|
||||
type: 'Link',
|
||||
},
|
||||
{
|
||||
label: 'Unsplash Access Key',
|
||||
name: 'unsplash_access_key',
|
||||
description:
|
||||
'Allows users to pick a profile cover image from Unsplash. https://unsplash.com/documentation#getting-started.',
|
||||
type: 'password',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Contact Us',
|
||||
icon: 'Phone',
|
||||
fields: [
|
||||
{
|
||||
label: 'Email',
|
||||
name: 'contact_us_email',
|
||||
type: 'text',
|
||||
description:
|
||||
'Users can reach out to this email for support or inquiries.',
|
||||
},
|
||||
{
|
||||
label: 'URL',
|
||||
name: 'contact_us_url',
|
||||
type: 'text',
|
||||
description:
|
||||
'Users can reach out to this URL for support or inquiries.',
|
||||
label: '',
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Livecode URL',
|
||||
name: 'livecode_url',
|
||||
doctype: 'Livecode URL',
|
||||
type: 'text',
|
||||
description:
|
||||
'https://docs.frappe.io/learning/falcon-self-hosting-guide',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Unsplash Access Key',
|
||||
name: 'unsplash_access_key',
|
||||
description:
|
||||
'Allows users to pick a profile cover image from Unsplash. https://unsplash.com/documentation#getting-started.',
|
||||
type: 'password',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -243,36 +304,45 @@ const tabsStructure = computed(() => {
|
||||
label: 'Configuration',
|
||||
icon: 'CreditCard',
|
||||
description: 'Manage all your payment related settings and defaults',
|
||||
fields: [
|
||||
sections: [
|
||||
{
|
||||
label: 'Default Currency',
|
||||
name: 'default_currency',
|
||||
type: 'Link',
|
||||
doctype: 'Currency',
|
||||
},
|
||||
{
|
||||
label: 'Payment Gateway',
|
||||
name: 'payment_gateway',
|
||||
type: 'Link',
|
||||
doctype: 'Payment Gateway',
|
||||
},
|
||||
{
|
||||
type: 'Column Break',
|
||||
},
|
||||
{
|
||||
label: 'Apply GST for India',
|
||||
name: 'apply_gst',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Show USD equivalent amount',
|
||||
name: 'show_usd_equivalent',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Apply rounding on equivalent',
|
||||
name: 'apply_rounding',
|
||||
type: 'checkbox',
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Default Currency',
|
||||
name: 'default_currency',
|
||||
type: 'Link',
|
||||
doctype: 'Currency',
|
||||
},
|
||||
{
|
||||
label: 'Payment Gateway',
|
||||
name: 'payment_gateway',
|
||||
type: 'Link',
|
||||
doctype: 'Payment Gateway',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Apply GST for India',
|
||||
name: 'apply_gst',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Show USD equivalent amount',
|
||||
name: 'show_usd_equivalent',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Apply rounding on equivalent',
|
||||
name: 'apply_rounding',
|
||||
type: 'checkbox',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -304,25 +374,33 @@ const tabsStructure = computed(() => {
|
||||
label: 'Branding',
|
||||
icon: 'Blocks',
|
||||
template: markRaw(BrandSettings),
|
||||
fields: [
|
||||
sections: [
|
||||
{
|
||||
label: 'Brand Name',
|
||||
name: 'app_name',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
label: 'Logo',
|
||||
name: 'banner_image',
|
||||
type: 'Upload',
|
||||
description:
|
||||
'Appears in the top left corner of the application to represent your brand.',
|
||||
},
|
||||
{
|
||||
label: 'Favicon',
|
||||
name: 'favicon',
|
||||
type: 'Upload',
|
||||
description:
|
||||
'Appears in the browser tab next to the page title to help users quickly identify the application.',
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Brand Name',
|
||||
name: 'app_name',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
label: 'Logo',
|
||||
name: 'banner_image',
|
||||
type: 'Upload',
|
||||
description:
|
||||
'Appears in the top left corner of the application to represent your brand.',
|
||||
},
|
||||
{
|
||||
label: 'Favicon',
|
||||
name: 'favicon',
|
||||
type: 'Upload',
|
||||
description:
|
||||
'Appears in the browser tab next to the page title to help users quickly identify the application.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -330,105 +408,124 @@ const tabsStructure = computed(() => {
|
||||
label: 'Sidebar',
|
||||
icon: 'PanelLeftIcon',
|
||||
description: 'Choose the items you want to show in the sidebar',
|
||||
fields: [
|
||||
sections: [
|
||||
{
|
||||
label: 'Courses',
|
||||
name: 'courses',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Batches',
|
||||
name: 'batches',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Programming Exercises',
|
||||
name: 'programming_exercises',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Certifications',
|
||||
name: 'certifications',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
type: 'Column Break',
|
||||
},
|
||||
{
|
||||
label: 'Jobs',
|
||||
name: 'jobs',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Statistics',
|
||||
name: 'statistics',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Notifications',
|
||||
name: 'notifications',
|
||||
type: 'checkbox',
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Courses',
|
||||
name: 'courses',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Batches',
|
||||
name: 'batches',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Programming Exercises',
|
||||
name: 'programming_exercises',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Certifications',
|
||||
name: 'certifications',
|
||||
type: 'checkbox',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Jobs',
|
||||
name: 'jobs',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Statistics',
|
||||
name: 'statistics',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Notifications',
|
||||
name: 'notifications',
|
||||
type: 'checkbox',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Signup',
|
||||
icon: 'LogIn',
|
||||
fields: [
|
||||
sections: [
|
||||
{
|
||||
label: 'Identify User Category',
|
||||
name: 'user_category',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'Enable this option to identify the user category during signup.',
|
||||
},
|
||||
{
|
||||
label: 'Disable signup',
|
||||
name: 'disable_signup',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'New users will have to be manually registered by Admins.',
|
||||
},
|
||||
{
|
||||
type: 'Column Break',
|
||||
},
|
||||
{
|
||||
label: 'Signup Consent HTML',
|
||||
name: 'custom_signup_content',
|
||||
type: 'Code',
|
||||
mode: 'htmlmixed',
|
||||
rows: 10,
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Identify User Category',
|
||||
name: 'user_category',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'Enable this option to identify the user category during signup.',
|
||||
},
|
||||
{
|
||||
label: 'Disable signup',
|
||||
name: 'disable_signup',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'New users will have to be manually registered by Admins.',
|
||||
},
|
||||
{
|
||||
label: 'Signup Consent HTML',
|
||||
name: 'custom_signup_content',
|
||||
type: 'Code',
|
||||
mode: 'htmlmixed',
|
||||
rows: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'SEO',
|
||||
icon: 'Search',
|
||||
fields: [
|
||||
sections: [
|
||||
{
|
||||
label: 'Meta Description',
|
||||
name: 'meta_description',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
description:
|
||||
"This description will be shown on lists and pages that don't have meta description",
|
||||
},
|
||||
{
|
||||
label: 'Meta Keywords',
|
||||
name: 'meta_keywords',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
description:
|
||||
'Comma separated keywords for search engines to find your website.',
|
||||
},
|
||||
{
|
||||
type: 'Column Break',
|
||||
},
|
||||
{
|
||||
label: 'Meta Image',
|
||||
name: 'meta_image',
|
||||
type: 'Upload',
|
||||
size: 'lg',
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Meta Description',
|
||||
name: 'meta_description',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
description:
|
||||
"This description will be shown on lists and pages that don't have meta description",
|
||||
},
|
||||
{
|
||||
label: 'Meta Keywords',
|
||||
name: 'meta_keywords',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
description:
|
||||
'Comma separated keywords for search engines to find your website.',
|
||||
},
|
||||
{
|
||||
label: 'Meta Image',
|
||||
name: 'meta_image',
|
||||
type: 'Upload',
|
||||
size: 'lg',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -202,60 +202,12 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
{{ __('Meta Image') }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!batch.image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="border rounded-md w-fit py-5 px-5 md:px-20 cursor-pointer"
|
||||
@click="openFileSelector"
|
||||
>
|
||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
{{ __('Upload') }}
|
||||
</Button>
|
||||
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
|
||||
{{
|
||||
__('Appears when the batch URL is shared on socials')
|
||||
}}
|
||||
</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-ink-gray-5 text-sm">
|
||||
{{
|
||||
__(
|
||||
'Appears when the batch URL is shared on any online platform'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Uploader
|
||||
v-model="batch.video_link"
|
||||
:label="__('Preview Video')"
|
||||
type="video"
|
||||
:required="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -292,6 +244,12 @@
|
||||
{{ __('Meta Tags') }}
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<Uploader
|
||||
v-model="batch.meta_image"
|
||||
:label="__('Meta Image')"
|
||||
type="image"
|
||||
:required="false"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="meta.description"
|
||||
:label="__('Meta Description')"
|
||||
@@ -345,8 +303,8 @@ import {
|
||||
openSettings,
|
||||
sanitizeHTML,
|
||||
updateMetaInfo,
|
||||
validateFile,
|
||||
} from '@/utils'
|
||||
import Uploader from '@/components/Controls/Uploader.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
@@ -380,11 +338,12 @@ const batch = reactive({
|
||||
category: '',
|
||||
allow_self_enrollment: false,
|
||||
certification: false,
|
||||
image: null,
|
||||
meta_image: null,
|
||||
paid_batch: false,
|
||||
currency: '',
|
||||
amount: 0,
|
||||
zoom_account: '',
|
||||
video_link: '',
|
||||
})
|
||||
|
||||
const meta = reactive({
|
||||
@@ -428,7 +387,8 @@ const newBatch = createResource({
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Batch',
|
||||
meta_image: batch.image?.file_url,
|
||||
meta_image: batch.image,
|
||||
video_link: batch.video_link,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
@@ -447,31 +407,38 @@ const batchDetail = createResource({
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key == 'instructors') {
|
||||
data.instructors.forEach((instructor) => {
|
||||
instructors.value.push(instructor.instructor)
|
||||
})
|
||||
} else if (['start_time', 'end_time'].includes(key)) {
|
||||
let [hours, minutes, seconds] = data[key].split(':')
|
||||
hours = hours.length == 1 ? '0' + hours : hours
|
||||
batch[key] = `${hours}:${minutes}`
|
||||
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
||||
})
|
||||
let checkboxes = [
|
||||
'published',
|
||||
'paid_batch',
|
||||
'allow_self_enrollment',
|
||||
'certification',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
batch[key] = batch[key] ? true : false
|
||||
}
|
||||
if (data.meta_image) imageResource.reload({ image: data.meta_image })
|
||||
updateBatchData(data)
|
||||
},
|
||||
})
|
||||
|
||||
const updateBatchData = (data) => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key == 'instructors') {
|
||||
data.instructors.forEach((instructor) => {
|
||||
instructors.value.push(instructor.instructor)
|
||||
})
|
||||
} else if (['start_time', 'end_time'].includes(key)) {
|
||||
batch[key] = formatTime(data[key])
|
||||
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
||||
})
|
||||
let checkboxes = [
|
||||
'published',
|
||||
'paid_batch',
|
||||
'allow_self_enrollment',
|
||||
'certification',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
batch[key] = batch[key] ? true : false
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timeStr) => {
|
||||
let [hours, minutes, seconds] = timeStr.split(':')
|
||||
hours = hours.length == 1 ? '0' + hours : hours
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
const editBatch = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
@@ -479,7 +446,8 @@ const editBatch = createResource({
|
||||
doctype: 'LMS Batch',
|
||||
name: props.batchName,
|
||||
fieldname: {
|
||||
meta_image: batch.image?.file_url,
|
||||
meta_image: batch.meta_image,
|
||||
video_link: batch.video_link,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
@@ -489,19 +457,6 @@ const editBatch = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const imageResource = createResource({
|
||||
url: 'lms.lms.api.get_file_info',
|
||||
makeParams(values) {
|
||||
return {
|
||||
file_url: values.image,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
batch.image = data
|
||||
},
|
||||
})
|
||||
|
||||
const validateFields = () => {
|
||||
batch.description = sanitizeHTML(batch.description)
|
||||
batch.batch_details = sanitizeHTML(batch.batch_details)
|
||||
@@ -603,14 +558,6 @@ const trashBatch = (close) => {
|
||||
})
|
||||
}
|
||||
|
||||
const saveImage = (file) => {
|
||||
batch.image = file
|
||||
}
|
||||
|
||||
const removeImage = () => {
|
||||
batch.image = null
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
|
||||
@@ -23,30 +23,120 @@
|
||||
v-if="notifications?.length"
|
||||
v-for="log in notifications"
|
||||
:key="log.name"
|
||||
class="flex items-center py-2 justify-between"
|
||||
class="flex space-x-2 px-2 py-4"
|
||||
:class="{
|
||||
'cursor-pointer': log.link,
|
||||
'items-center': !showDetails(log) && !isMention(log),
|
||||
}"
|
||||
@click="navigateToPage(log)"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Avatar :image="log.user_image" :label="log.full_name" class="mr-2" />
|
||||
<div class="notification text-ink-gray-7" v-html="log.subject"></div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<a
|
||||
v-if="log.link"
|
||||
:href="log.link"
|
||||
@click="(e) => handleMarkAsRead(e, log.name)"
|
||||
class="text-ink-gray-5 font-medium text-sm hover:text-ink-gray-7"
|
||||
<Avatar
|
||||
:image="log.from_user_details.user_image"
|
||||
size="xl"
|
||||
:label="log.from_user_details.full_name"
|
||||
/>
|
||||
<div class="space-y-2 w-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="text-ink-gray-9" v-html="log.subject"></div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ dayjs(log.creation).fromNow() }}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
v-if="!log.read"
|
||||
@click.stop="(e) => handleMarkAsRead(log.name)"
|
||||
>
|
||||
<template #icon>
|
||||
<X class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isMention(log)"
|
||||
v-html="log.email_content"
|
||||
class="bg-surface-gray-2 rounded-md px-3 py-2"
|
||||
></div>
|
||||
<div
|
||||
v-else-if="showDetails(log)"
|
||||
class="flex items-stretch border border-outline-gray-2 space-x-2 rounded-md"
|
||||
>
|
||||
{{ __('View') }}
|
||||
</a>
|
||||
<Button
|
||||
variant="ghost"
|
||||
v-if="!log.read"
|
||||
@click.stop="(e) => handleMarkAsRead(e, log.name)"
|
||||
>
|
||||
<template #icon>
|
||||
<X class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<iframe
|
||||
v-if="
|
||||
log.document_type == 'LMS Course' &&
|
||||
log.document_details.video_link
|
||||
"
|
||||
:src="`https://www.youtube.com/embed/${log.document_details.video_link}`"
|
||||
class="rounded-l-md w-72"
|
||||
/>
|
||||
<video
|
||||
v-else-if="
|
||||
log.document_type == 'LMS Batch' &&
|
||||
log.document_details.video_link
|
||||
"
|
||||
:src="log.document_details.video_link"
|
||||
class="rounded-l-md w-72"
|
||||
/>
|
||||
<div class="p-3">
|
||||
<div
|
||||
class="bg-surface-violet-1 w-fit py-1 px-1.5 rounded-full text-ink-violet-1 text-sm mb-2"
|
||||
>
|
||||
{{
|
||||
log.document_type === 'LMS Course'
|
||||
? __('New Course')
|
||||
: __('New Batch')
|
||||
}}
|
||||
</div>
|
||||
<div class="font-semibold mb-1">
|
||||
{{ __(log.document_details.title) }}
|
||||
</div>
|
||||
<div class="leading-5">
|
||||
{{ __(log.document_details.short_introduction) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="log.document_details.start_date"
|
||||
class="flex items-center space-x-2 text-sm mt-5"
|
||||
>
|
||||
<Calendar class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
{{
|
||||
dayjs(log.document_details.start_date).format('DD MMM YYYY')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="log.document_details.start_time"
|
||||
class="flex items-center space-x-2 text-sm mt-2"
|
||||
>
|
||||
<Clock class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
{{ formatTime(log.document_details.start_time) }}
|
||||
{{ log.document_details.timezone }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="log.document_details.instructors.length > 1"
|
||||
class="space-y-2 mt-5"
|
||||
>
|
||||
<div
|
||||
v-for="instructor in log.document_details.instructors"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<Avatar
|
||||
:size="'sm'"
|
||||
:image="instructor.user_image"
|
||||
:label="instructor.full_name"
|
||||
/>
|
||||
<span class="font-medium text-sm">
|
||||
{{ instructor.full_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-ink-gray-5">
|
||||
@@ -57,17 +147,19 @@
|
||||
<script setup>
|
||||
import {
|
||||
Avatar,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
dayjs,
|
||||
TabButtons,
|
||||
Button,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { computed, inject, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { Calendar, Clock, X } from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils/'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const user = inject('$user')
|
||||
@@ -132,10 +224,62 @@ const markAllAsRead = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const handleMarkAsRead = (e, logName) => {
|
||||
const handleMarkAsRead = (logName) => {
|
||||
markAsRead.submit({ name: logName })
|
||||
}
|
||||
|
||||
const navigateToPage = (log) => {
|
||||
if (!log.link) return
|
||||
handleMarkAsRead(log.name)
|
||||
let link = log.link.split('/')
|
||||
if (link[2] == 'courses') {
|
||||
router.push({
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: link[3] },
|
||||
})
|
||||
} else if (link.includes('batches')) {
|
||||
if (link.includes('details')) {
|
||||
router.push({
|
||||
name: 'BatchDetail',
|
||||
params: { batchName: link.pop() },
|
||||
})
|
||||
} else {
|
||||
router.push({
|
||||
name: 'Batch',
|
||||
params: { batchName: link.pop() },
|
||||
})
|
||||
}
|
||||
} else if (link.includes('assignment-submission')) {
|
||||
router.push({
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
submissionName: link[4],
|
||||
assignmentID: link[3],
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isMention = (log) => {
|
||||
if (log.type == 'Mention') {
|
||||
return true
|
||||
}
|
||||
if (log.subject.includes('mentioned you')) {
|
||||
return true
|
||||
}
|
||||
if (log.subject.includes('comment')) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const showDetails = (log) => {
|
||||
return (
|
||||
['LMS Course', 'LMS Batch'].includes(log.document_type) &&
|
||||
log.document_details
|
||||
)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
socket.off('publish_lms_notifications')
|
||||
})
|
||||
@@ -159,11 +303,3 @@ usePageMeta(() => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.notification strong {
|
||||
font-weight: 400;
|
||||
}
|
||||
.notification b {
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -618,15 +618,18 @@ export function singularize(word) {
|
||||
)
|
||||
}
|
||||
|
||||
export const validateFile = async (file, showToast = true) => {
|
||||
export const validateFile = async (
|
||||
file,
|
||||
showToast = true,
|
||||
fileType = 'image'
|
||||
) => {
|
||||
const error = (msg) => {
|
||||
if (showToast) toast.error(msg)
|
||||
console.error(msg)
|
||||
return msg
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return error(__('Only image file is allowed.'))
|
||||
if (!file.type.startsWith(`${fileType}/`)) {
|
||||
return error(__('Only {0} file is allowed.').format(fileType))
|
||||
}
|
||||
|
||||
if (file.type === 'image/svg+xml') {
|
||||
|
||||
@@ -30,7 +30,7 @@ export default defineConfig(async ({ mode }) => {
|
||||
cleanupOutdatedCaches: true,
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
globDirectory: '/assets/lms/frontend',
|
||||
globPatterns: ['**/*.{js,ts,css,html,png,svg}'],
|
||||
globPatterns: ['**/*.{js,ts,css,html,svg}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ request }) =>
|
||||
|
||||
+336
-476
File diff suppressed because it is too large
Load Diff
@@ -132,6 +132,7 @@ scheduler_events = {
|
||||
"lms.lms.doctype.lms_payment.lms_payment.send_payment_reminder",
|
||||
"lms.lms.doctype.lms_batch.lms_batch.send_batch_start_reminder",
|
||||
"lms.lms.doctype.lms_live_class.lms_live_class.send_live_class_reminder",
|
||||
"lms.lms.doctype.lms_course.lms_course.send_notification_for_published_courses",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
+72
-4
@@ -29,7 +29,13 @@ from frappe.utils import (
|
||||
from frappe.utils.response import Response
|
||||
|
||||
from lms.lms.doctype.course_lesson.course_lesson import save_progress
|
||||
from lms.lms.utils import get_average_rating, get_batch_details, get_course_details, get_lesson_count
|
||||
from lms.lms.utils import (
|
||||
get_average_rating,
|
||||
get_batch_details,
|
||||
get_course_details,
|
||||
get_instructors,
|
||||
get_lesson_count,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
@@ -1197,17 +1203,79 @@ def get_notifications(filters):
|
||||
notifications = frappe.get_all(
|
||||
"Notification Log",
|
||||
filters,
|
||||
["subject", "from_user", "link", "read", "name"],
|
||||
[
|
||||
"subject",
|
||||
"from_user",
|
||||
"link",
|
||||
"read",
|
||||
"name",
|
||||
"creation",
|
||||
"document_type",
|
||||
"document_name",
|
||||
"type",
|
||||
"email_content",
|
||||
],
|
||||
order_by="creation desc",
|
||||
)
|
||||
|
||||
for notification in notifications:
|
||||
notification = update_document_details(notification)
|
||||
notification = update_user_details(notification)
|
||||
|
||||
return notifications
|
||||
|
||||
|
||||
def update_user_details(notification):
|
||||
if (
|
||||
notification.document_details
|
||||
and len(notification.document_details.get("instructors", []))
|
||||
and not is_mention(notification)
|
||||
):
|
||||
from_user_details = notification.document_details["instructors"][0]
|
||||
else:
|
||||
from_user_details = frappe.db.get_value(
|
||||
"User", notification.from_user, ["full_name", "user_image"], as_dict=1
|
||||
)
|
||||
notification.update(from_user_details)
|
||||
notification["from_user_details"] = from_user_details
|
||||
return notification
|
||||
|
||||
return notifications
|
||||
|
||||
def is_mention(notification):
|
||||
if notification.type == "Mention":
|
||||
return True
|
||||
if "mentioned you" in notification.subject.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def update_document_details(notification):
|
||||
if notification.document_type == "LMS Course":
|
||||
details = frappe.db.get_value(
|
||||
"LMS Course", notification.document_name, ["title", "video_link", "short_introduction"], as_dict=1
|
||||
)
|
||||
instructors = get_instructors("LMS Course", notification.document_name)
|
||||
details["instructors"] = instructors
|
||||
notification["document_details"] = details
|
||||
|
||||
elif notification.document_type == "LMS Batch":
|
||||
details = frappe.db.get_value(
|
||||
"LMS Batch",
|
||||
notification.document_name,
|
||||
[
|
||||
"title",
|
||||
"description as short_introduction",
|
||||
"video_link",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"start_time",
|
||||
"timezone",
|
||||
],
|
||||
as_dict=1,
|
||||
)
|
||||
instructors = get_instructors("LMS Batch", notification.document_name)
|
||||
details["instructors"] = instructors
|
||||
notification["document_details"] = details
|
||||
return notification
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
|
||||
@@ -64,16 +64,21 @@ class LMSAssignmentSubmission(Document):
|
||||
def trigger_update_notification(self):
|
||||
notification = frappe._dict(
|
||||
{
|
||||
"subject": _("There has been an update on your submission for assignment {0}").format(
|
||||
self.assignment_title
|
||||
"subject": _("The instructor has left a comment on your assignment {0}").format(
|
||||
frappe.bold(self.assignment_title)
|
||||
),
|
||||
"email_content": self.comments,
|
||||
"document_type": self.doctype,
|
||||
"document_name": self.name,
|
||||
"for_user": self.owner,
|
||||
"from_user": self.evaluator,
|
||||
"type": "Alert",
|
||||
"link": f"/lms/assignment-submission/{self.assignment}/{self.name}",
|
||||
"link": f"/lms/assignment-submission/{self.assignment}/{self.name}",
|
||||
}
|
||||
)
|
||||
make_notification_logs(notification, [self.member])
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def upload_assignment(
|
||||
assignment_attachment=None,
|
||||
answer=None,
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
"column_break_flwy",
|
||||
"seat_count",
|
||||
"evaluation_end_date",
|
||||
"notification_sent",
|
||||
"section_break_jedp",
|
||||
"video_link",
|
||||
"column_break_kpct",
|
||||
"meta_image",
|
||||
"section_break_khcn",
|
||||
"batch_details",
|
||||
@@ -361,6 +365,26 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Zoom Account",
|
||||
"options": "LMS Zoom Settings"
|
||||
},
|
||||
{
|
||||
"fieldname": "video_link",
|
||||
"fieldtype": "Attach",
|
||||
"label": "Preview Video"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "notification_sent",
|
||||
"fieldtype": "Check",
|
||||
"label": "Notification Sent",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jedp",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_kpct",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -383,7 +407,7 @@
|
||||
"link_fieldname": "payment_for_document"
|
||||
}
|
||||
],
|
||||
"modified": "2025-12-23 11:27:00.424331",
|
||||
"modified": "2026-01-13 18:50:27.420712",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Batch",
|
||||
|
||||
@@ -8,12 +8,14 @@ from datetime import timedelta
|
||||
import frappe
|
||||
import requests
|
||||
from frappe import _
|
||||
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import add_days, cint, format_datetime, get_time, nowdate
|
||||
|
||||
from lms.lms.utils import (
|
||||
generate_slug,
|
||||
get_assignment_details,
|
||||
get_instructors,
|
||||
get_lesson_index,
|
||||
get_lesson_url,
|
||||
get_quiz_details,
|
||||
@@ -33,6 +35,10 @@ class LMSBatch(Document):
|
||||
self.validate_timetable()
|
||||
self.validate_evaluation_end_date()
|
||||
|
||||
def on_update(self):
|
||||
if self.has_value_changed("published") and self.published:
|
||||
frappe.enqueue(send_notification_for_published_batch, batch=self, now=True)
|
||||
|
||||
def autoname(self):
|
||||
if not self.name:
|
||||
self.name = generate_slug(self.title, "LMS Batch")
|
||||
@@ -123,6 +129,77 @@ class LMSBatch(Document):
|
||||
update_payment_record("LMS Batch", self.name)
|
||||
|
||||
|
||||
def send_notification_for_published_batch(batch):
|
||||
send_notification = frappe.db.get_single_value("LMS Settings", "send_notification_for_published_batches")
|
||||
if not send_notification:
|
||||
return
|
||||
|
||||
if not batch.published:
|
||||
return
|
||||
if batch.notification_sent:
|
||||
return
|
||||
|
||||
if send_notification == "Email":
|
||||
send_email_notification_for_published_batch(batch)
|
||||
else:
|
||||
send_system_notification_for_published_batch(batch)
|
||||
|
||||
|
||||
def send_email_notification_for_published_batch(batch):
|
||||
brand_name = frappe.db.get_single_value("Website Settings", "app_name")
|
||||
brand_logo = frappe.db.get_single_value("Website Settings", "banner_image")
|
||||
subject = _("A new course has been published on {0}").format(brand_name)
|
||||
template = "published_batch_notification"
|
||||
students = frappe.get_all("User", {"enabled": 1}, pluck="name")
|
||||
instructors = get_instructors("LMS Batch", batch.name)
|
||||
|
||||
args = {
|
||||
"brand_logo": brand_logo,
|
||||
"brand_name": brand_name,
|
||||
"title": batch.title,
|
||||
"short_introduction": batch.description,
|
||||
"start_date": batch.start_date,
|
||||
"end_date": batch.end_date,
|
||||
"start_time": batch.start_time,
|
||||
"medium": batch.medium,
|
||||
"timezone": batch.timezone,
|
||||
"instructors": instructors,
|
||||
"batch_url": f"{frappe.utils.get_url()}/lms/batches/details/{batch.name}",
|
||||
}
|
||||
|
||||
frappe.sendmail(
|
||||
recipients=instructors,
|
||||
bcc=students,
|
||||
subject=subject,
|
||||
template=template,
|
||||
args=args,
|
||||
)
|
||||
frappe.db.set_value("LMS Batch", batch.name, "notification_sent", 1)
|
||||
|
||||
|
||||
def send_system_notification_for_published_batch(batch):
|
||||
students = frappe.get_all("User", {"enabled": 1}, pluck="name")
|
||||
instructors = frappe.get_all("Course Instructor", {"parent": batch.name}, pluck="instructor")
|
||||
instructor_name = frappe.db.get_value("User", instructors[0], "full_name")
|
||||
notification = frappe._dict(
|
||||
{
|
||||
"subject": _("{0} has published a new batch {1}").format(
|
||||
frappe.bold(instructor_name), frappe.bold(batch.title)
|
||||
),
|
||||
"email_content": _(
|
||||
"A new batch '{0}' has been published that might interest you. Check it out!"
|
||||
).format(batch.title),
|
||||
"document_type": "LMS Batch",
|
||||
"document_name": batch.name,
|
||||
"from_user": instructors[0] if instructors else None,
|
||||
"type": "Alert",
|
||||
"link": f"/lms/batches/details/{batch.name}",
|
||||
}
|
||||
)
|
||||
make_notification_logs(notification, students)
|
||||
frappe.db.set_value("LMS Batch", batch.name, "notification_sent", 1)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_live_class(
|
||||
batch_name,
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
"statistics_section",
|
||||
"enrollments",
|
||||
"lessons",
|
||||
"rating"
|
||||
"rating",
|
||||
"notification_sent"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -288,6 +289,13 @@
|
||||
"fieldname": "timezone",
|
||||
"fieldtype": "Data",
|
||||
"label": "Timezone"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "notification_sent",
|
||||
"fieldtype": "Check",
|
||||
"label": "Notification Sent",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"is_published_field": "published",
|
||||
@@ -306,7 +314,7 @@
|
||||
}
|
||||
],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2025-12-15 15:15:42.226098",
|
||||
"modified": "2026-01-13 18:48:56.069280",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Course",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# Copyright (c) 2021, Frappe and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import json
|
||||
import random
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, today
|
||||
|
||||
from ...utils import generate_slug, update_payment_record, validate_image
|
||||
from ...utils import generate_slug, get_instructors, update_payment_record, validate_image
|
||||
|
||||
|
||||
class LMSCourse(Document):
|
||||
@@ -131,3 +131,79 @@ class LMSCourse(Document):
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Course#{self.name}>"
|
||||
|
||||
|
||||
def send_notification_for_published_courses():
|
||||
send_notification = frappe.db.get_single_value("LMS Settings", "send_notification_for_published_courses")
|
||||
if not send_notification:
|
||||
return
|
||||
|
||||
courses_published_today = frappe.get_all(
|
||||
"LMS Course",
|
||||
{
|
||||
"published_on": today(),
|
||||
"notification_sent": 0,
|
||||
},
|
||||
["name", "title", "short_introduction"],
|
||||
)
|
||||
|
||||
if not courses_published_today:
|
||||
return
|
||||
|
||||
if send_notification == "Email":
|
||||
send_email_notification_for_published_courses(courses_published_today)
|
||||
else:
|
||||
send_system_notification_for_published_courses(courses_published_today)
|
||||
|
||||
|
||||
def send_email_notification_for_published_courses(courses):
|
||||
brand_name = frappe.db.get_single_value("Website Settings", "app_name")
|
||||
brand_logo = frappe.db.get_single_value("Website Settings", "banner_image")
|
||||
subject = _("A new course has been published on {0}").format(brand_name)
|
||||
template = "published_course_notification"
|
||||
students = frappe.get_all("User", {"enabled": 1}, pluck="name")
|
||||
|
||||
for course in courses:
|
||||
instructors = get_instructors("LMS Course", course.name)
|
||||
|
||||
args = {
|
||||
"brand_logo": brand_logo,
|
||||
"brand_name": brand_name,
|
||||
"title": course.title,
|
||||
"short_introduction": course.short_introduction,
|
||||
"instructors": instructors,
|
||||
"course_url": f"{frappe.utils.get_url()}/lms/courses/{course.name}",
|
||||
}
|
||||
|
||||
frappe.sendmail(
|
||||
recipients=instructors,
|
||||
bcc=students,
|
||||
subject=subject,
|
||||
template=template,
|
||||
args=args,
|
||||
)
|
||||
frappe.db.set_value("LMS Course", course.name, "notification_sent", 1)
|
||||
|
||||
|
||||
def send_system_notification_for_published_courses(courses):
|
||||
for course in courses:
|
||||
students = frappe.get_all("User", {"enabled": 1}, pluck="name")
|
||||
instructors = frappe.get_all("Course Instructor", {"parent": course.name}, pluck="instructor")
|
||||
instructor_name = frappe.db.get_value("User", instructors[0], "full_name")
|
||||
notification = frappe._dict(
|
||||
{
|
||||
"subject": _("{0} has published a new course {1}").format(
|
||||
frappe.bold(instructor_name), frappe.bold(course.title)
|
||||
),
|
||||
"email_content": _(
|
||||
"A new course '{0}' has been published that might interest you. Check it out!"
|
||||
).format(course.title),
|
||||
"document_type": "LMS Course",
|
||||
"document_name": course.name,
|
||||
"from_user": instructors[0] if instructors else None,
|
||||
"type": "Alert",
|
||||
"link": f"/lms/courses/{course.name}",
|
||||
}
|
||||
)
|
||||
make_notification_logs(notification, students)
|
||||
frappe.db.set_value("LMS Course", course.name, "notification_sent", 1)
|
||||
|
||||
@@ -54,15 +54,15 @@ class LMSQuizSubmission(Document):
|
||||
notification = frappe._dict(
|
||||
{
|
||||
"subject": _("You have got a score of {0} for the quiz {1}").format(
|
||||
self.score, self.quiz_title
|
||||
(frappe.bold(self.score)), frappe.bold(self.quiz_title)
|
||||
),
|
||||
"email_content": _(
|
||||
"There has been an update on your submission. You have got a score of {0} for the quiz {1}"
|
||||
).format(self.score, self.quiz_title),
|
||||
).format(frappe.bold(self.score), frappe.bold(self.quiz_title)),
|
||||
"document_type": self.doctype,
|
||||
"document_name": self.name,
|
||||
"for_user": self.member,
|
||||
"from_user": "Administrator",
|
||||
"from_user": frappe.session.user,
|
||||
"type": "Alert",
|
||||
"link": "",
|
||||
}
|
||||
|
||||
@@ -6,16 +6,20 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"general_tab",
|
||||
"default_home",
|
||||
"send_calendar_invite_for_evaluations",
|
||||
"persona_captured",
|
||||
"column_break_zdel",
|
||||
"allow_guest_access",
|
||||
"prevent_skipping_videos",
|
||||
"send_calendar_invite_for_evaluations",
|
||||
"column_break_zdel",
|
||||
"disable_pwa",
|
||||
"persona_captured",
|
||||
"default_home",
|
||||
"column_break_bjis",
|
||||
"unsplash_access_key",
|
||||
"livecode_url",
|
||||
"notifications_section",
|
||||
"send_notification_for_published_courses",
|
||||
"column_break_dtns",
|
||||
"send_notification_for_published_batches",
|
||||
"section_break_szgq",
|
||||
"show_day_view",
|
||||
"column_break_2",
|
||||
@@ -446,13 +450,34 @@
|
||||
"fieldname": "disable_pwa",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disable PWA"
|
||||
},
|
||||
{
|
||||
"fieldname": "send_notification_for_published_courses",
|
||||
"fieldtype": "Select",
|
||||
"label": "Send Notification for Published Courses",
|
||||
"options": "\nEmail\nIn-app"
|
||||
},
|
||||
{
|
||||
"fieldname": "send_notification_for_published_batches",
|
||||
"fieldtype": "Select",
|
||||
"label": "Send Notification for Published Batches",
|
||||
"options": "\nEmail\nIn-app"
|
||||
},
|
||||
{
|
||||
"fieldname": "notifications_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Notifications"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_dtns",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-12-22 11:30:13.868031",
|
||||
"modified": "2026-01-01 19:36:54.443390",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Settings",
|
||||
|
||||
+46
-22
@@ -398,26 +398,47 @@ def handle_notifications(doc, method):
|
||||
notify_mentions_via_email(doc, topic)
|
||||
|
||||
|
||||
def create_notification_log(doc, topic):
|
||||
def get_course_details_for_notification(topic):
|
||||
users = []
|
||||
course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course")
|
||||
course_title = frappe.db.get_value("LMS Course", course, "title")
|
||||
instructors = frappe.db.get_all(
|
||||
"Course Instructor", {"parent": course, "parenttype": "LMS Course"}, pluck="instructor"
|
||||
)
|
||||
|
||||
users.append(topic.owner)
|
||||
users += instructors
|
||||
|
||||
subject = _("New reply on the topic {0} in course {1}").format(topic.title, course_title)
|
||||
link = get_lesson_url(course, get_lesson_index(topic.reference_docname))
|
||||
|
||||
return subject, link, users
|
||||
|
||||
|
||||
def get_batch_details_for_notification(topic):
|
||||
users = []
|
||||
batch_title = frappe.db.get_value("LMS Batch", topic.reference_docname, "title")
|
||||
subject = _("New comment in batch {0}").format(batch_title)
|
||||
link = f"/lms/batches/{topic.reference_docname}"
|
||||
instructors = frappe.db.get_all(
|
||||
"Course Instructor",
|
||||
{"parenttype": "LMS Batch", "parent": topic.reference_docname},
|
||||
pluck="instructor",
|
||||
)
|
||||
students = frappe.db.get_all("LMS Batch Enrollment", {"batch": topic.reference_docname}, pluck="member")
|
||||
users += instructors
|
||||
users += students
|
||||
return subject, link, users
|
||||
|
||||
|
||||
def create_notification_log(doc, topic):
|
||||
if topic.reference_doctype == "Course Lesson":
|
||||
course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course")
|
||||
course_title = frappe.db.get_value("LMS Course", course, "title")
|
||||
instructors = frappe.db.get_all("Course Instructor", {"parent": course}, pluck="instructor")
|
||||
|
||||
if doc.owner != topic.owner:
|
||||
users.append(topic.owner)
|
||||
|
||||
users += instructors
|
||||
subject = _("New reply on the topic {0} in course {1}").format(topic.title, course_title)
|
||||
link = get_lesson_url(course, get_lesson_index(topic.reference_docname))
|
||||
|
||||
subject, link, users = get_course_details_for_notification(topic)
|
||||
else:
|
||||
batch_title = frappe.db.get_value("LMS Batch", topic.reference_docname, "title")
|
||||
subject = _("New comment in batch {0}").format(batch_title)
|
||||
link = f"/batches/{topic.reference_docname}"
|
||||
moderators = frappe.get_all("Has Role", {"role": "Moderator"}, pluck="parent")
|
||||
users += moderators
|
||||
subject, link, users = get_batch_details_for_notification(topic)
|
||||
|
||||
if doc.owner in users:
|
||||
users.remove(doc.owner)
|
||||
|
||||
notification = frappe._dict(
|
||||
{
|
||||
@@ -425,7 +446,6 @@ def create_notification_log(doc, topic):
|
||||
"email_content": doc.reply,
|
||||
"document_type": topic.reference_doctype,
|
||||
"document_name": topic.reference_docname,
|
||||
"for_user": topic.owner,
|
||||
"from_user": doc.owner,
|
||||
"type": "Alert",
|
||||
"link": link,
|
||||
@@ -444,12 +464,16 @@ def notify_mentions_on_portal(doc, topic):
|
||||
|
||||
if topic.reference_doctype == "Course Lesson":
|
||||
course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course")
|
||||
subject = _("{0} mentioned you in a comment in {1}").format(from_user_name, topic.title)
|
||||
subject = _("{0} mentioned you in a comment in {1}").format(
|
||||
frappe.bold(from_user_name), frappe.bold(topic.title)
|
||||
)
|
||||
link = get_lesson_url(course, get_lesson_index(topic.reference_docname))
|
||||
else:
|
||||
batch_title = frappe.db.get_value("LMS Batch", topic.reference_docname, "title")
|
||||
subject = _("{0} mentioned you in a comment in {1}").format(from_user_name, batch_title)
|
||||
link = f"/batches/{topic.reference_docname}"
|
||||
subject = _("{0} mentioned you in a comment in {1}").format(
|
||||
frappe.bold(from_user_name), frappe.bold(batch_title)
|
||||
)
|
||||
link = f"/lms/batches/{topic.reference_docname}#discussions"
|
||||
|
||||
for user in mentions:
|
||||
notification = frappe._dict(
|
||||
@@ -460,7 +484,7 @@ def notify_mentions_on_portal(doc, topic):
|
||||
"document_name": topic.reference_docname,
|
||||
"for_user": user,
|
||||
"from_user": doc.owner,
|
||||
"type": "Alert",
|
||||
"type": "Mention",
|
||||
"link": link,
|
||||
}
|
||||
)
|
||||
|
||||
+211
-165
File diff suppressed because it is too large
Load Diff
+212
-166
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+212
-166
File diff suppressed because it is too large
Load Diff
+212
-166
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+212
-166
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+212
-166
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+212
-166
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+212
-166
File diff suppressed because it is too large
Load Diff
+212
-166
File diff suppressed because it is too large
Load Diff
+212
-166
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+211
-165
File diff suppressed because it is too large
Load Diff
+212
-166
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
<div style="width: 70%; margin: 0 auto;">
|
||||
<img src="{{ brand_logo }}" style="width: 30px; height: 30px;" />
|
||||
<p style="font-size: 16px; font-weight: 600;">
|
||||
{{ _("Hello Learner") }},
|
||||
</p>
|
||||
<p>
|
||||
{{ _("A new batch has been published on ")}} {{ brand_name }} {{ _("that might interest you!") }} {{ _("Here are the details:") }}
|
||||
</p>
|
||||
<div style="background-color: #F8F8F8; border-radius: 12px; padding: 12px; margin-bottom: 6px;">
|
||||
<div style="font-weight: 600; margin-bottom: 6px; font-size: 15px;">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div>
|
||||
{{ short_introduction }}
|
||||
</div>
|
||||
<div style="margin-top: 20px; font-size: 13px;">
|
||||
{% if end_date %}
|
||||
<span>
|
||||
{{ _("From ") }} {{ frappe.utils.format_date(start_date, "dd MMM YYYY") }} {{ _(" to ") }} {{ frappe.utils.format_date(end_date, "dd MMM YYYY") }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span>
|
||||
{{ frappe.utils.format_date(start_date, "dd MMM YYYY") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="color: #525252; margin-top: 4px; font-size: 13px;">
|
||||
<span>
|
||||
{{ _("Time: ") }} {{ frappe.utils.format_time(start_time, "HH:mm a") }} {{ timezone }}
|
||||
</span>
|
||||
</div>
|
||||
<div style="margin-top: 20px;">
|
||||
{% for instructor in instructors %}
|
||||
<div style="display: flex; align-items: center; margin-bottom: 5px;">
|
||||
{% if instructor.user_image %}
|
||||
<img src="{{ instructor.user_image }}" style="width: 20px; height: 20px; border-radius: 50%; margin-right: 5px;" />
|
||||
{% else %}
|
||||
<div style="width: 20px; height: 20px; border-radius: 50%; background-color: #ccc; display: flex; align-items: center; justify-content: center; margin-right: 5px;">
|
||||
<span style="font-size: 12px; color: #fff;">
|
||||
{{ instructor.full_name.split("")[0] | upper }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
{{ instructor.full_name }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ batch_url }}" style="display: inline-block; padding: 4px 8px; background-color: #171717; color: #fff; text-decoration: none; cursor: pointer; border-radius: 8px; margin-top: 10px;">
|
||||
{{ _("Checkout the batch") }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
<div style="width: 70%; margin: 0 auto;">
|
||||
<img src="{{ brand_logo }}" style="width: 30px; height: 30px;" />
|
||||
<p style="font-size: 16px; font-weight: 600;">
|
||||
{{ _("Hello Learner") }},
|
||||
</p>
|
||||
<p>
|
||||
{{ _("A new course has been published on ")}} {{ brand_name }} {{ _("that might interest you!") }} {{ _("Here are the details:") }}
|
||||
</p>
|
||||
<div style="background-color: #F8F8F8; border-radius: 12px; padding: 12px; margin-bottom: 6px;">
|
||||
<div style="font-weight: 600; margin-bottom: 6px;">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div>
|
||||
{{ short_introduction }}
|
||||
</div>
|
||||
<div style="margin-top: 20px;">
|
||||
{% for instructor in instructors %}
|
||||
<div style="display: flex; align-items: center; margin-bottom: 5px;">
|
||||
{% if instructor.user_image %}
|
||||
<img src="{{ instructor.user_image }}" style="width: 20px; height: 20px; border-radius: 50%; margin-right: 5px;" />
|
||||
{% else %}
|
||||
<div style="width: 20px; height: 20px; border-radius: 50%; background-color: #ccc; display: flex; align-items: center; justify-content: center; margin-right: 5px;">
|
||||
<span style="font-size: 12px; color: #fff;">
|
||||
{{ instructor.full_name.split("")[0] | upper }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
{{ instructor.full_name }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ course_url }}" style="display: inline-block; padding: 4px 8px; background-color: #171717; color: #fff; text-decoration: none; cursor: pointer; border-radius: 8px; margin-top: 10px;">
|
||||
{{ _("Checkout the course") }}
|
||||
</a>
|
||||
</div>
|
||||
Reference in New Issue
Block a user