Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc81f1aa26 | ||
|
|
59d8848125 | ||
|
|
a067695f71 | ||
|
|
be870e8145 | ||
|
|
8a17dca351 | ||
|
|
1c9f636ad1 | ||
|
|
008cc66cdd | ||
|
|
b6bf9c0032 | ||
|
|
d295898674 | ||
|
|
4fdca4691a | ||
|
|
7c055af496 | ||
|
|
60a3da283e | ||
|
|
576258ec6e | ||
|
|
01120fbc48 | ||
|
|
ad07f883b5 | ||
|
|
bb9b179e05 | ||
|
|
11a9bff57d | ||
|
|
e18f0c9dad | ||
|
|
41ad3d00de | ||
|
|
b74c1670ca | ||
|
|
33c76e842f | ||
|
|
35a7cce283 | ||
|
|
e0f569c382 | ||
|
|
d8ab88be28 | ||
|
|
04552bdef6 | ||
|
|
ad5bf89b35 | ||
|
|
88b38dfd83 | ||
|
|
75e9ca395f | ||
|
|
6fb206cc4e | ||
|
|
62cb198492 | ||
|
|
9609329f01 | ||
|
|
c93808af94 | ||
|
|
58866260ec | ||
|
|
e6157ff411 | ||
|
|
8cca8920ee | ||
|
|
ab039dbd46 | ||
|
|
9853ab3fd9 | ||
|
|
dc2bf9f13e | ||
|
|
7c90ca4040 | ||
|
|
75a90e1f39 | ||
|
|
bc4b17cc3d | ||
|
|
8c454a333e | ||
|
|
cef4b70182 | ||
|
|
3cda563583 | ||
|
|
545326a02a | ||
|
|
14ce5d7e23 |
@@ -31,12 +31,35 @@ describe("Course Creation", () => {
|
||||
.contains("Preview Video")
|
||||
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
|
||||
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
|
||||
cy.get(".search-input").click().type("frappe");
|
||||
cy.wait(1000);
|
||||
cy.get("label")
|
||||
.contains("Category")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("button").click();
|
||||
});
|
||||
cy.get("[id^=headlessui-combobox-option-")
|
||||
.should("be.visible")
|
||||
.first()
|
||||
.click();
|
||||
|
||||
/* Instructor */
|
||||
cy.get("label")
|
||||
.contains("Instructors")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("input").click().type("frappe");
|
||||
cy.get("input")
|
||||
.invoke("attr", "aria-controls")
|
||||
.as("instructor_list_id");
|
||||
});
|
||||
cy.get("@instructor_list_id").then((instructor_list_id) => {
|
||||
cy.get(`[id^=${instructor_list_id}`)
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.get("[id^=headlessui-combobox-option-").first().click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.get("label").contains("Published").click();
|
||||
cy.get("label").contains("Published On").type("2021-01-01");
|
||||
cy.button("Save").click();
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@editorjs/paragraph": "^2.11.3",
|
||||
"@editorjs/simple-image": "^1.6.0",
|
||||
"chart.js": "^4.4.1",
|
||||
"codemirror-editor-vue3": "^2.8.0",
|
||||
"dayjs": "^1.11.6",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.69",
|
||||
|
||||
@@ -14,8 +14,10 @@ import DesktopLayout from './components/DesktopLayout.vue'
|
||||
import MobileLayout from './components/MobileLayout.vue'
|
||||
import { stopSession } from '@/telemetry'
|
||||
import { init as initTelemetry } from '@/telemetry'
|
||||
import { usersStore } from '@/stores/user'
|
||||
|
||||
const screenSize = useScreenSize()
|
||||
let { userResource } = usersStore()
|
||||
|
||||
const Layout = computed(() => {
|
||||
if (screenSize.width < 640) {
|
||||
@@ -26,6 +28,7 @@ const Layout = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!userResource.data) return
|
||||
await initTelemetry()
|
||||
})
|
||||
|
||||
|
||||
83
frontend/src/components/BrandSettings.vue
Normal file
83
frontend/src/components/BrandSettings.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold mb-1">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<SettingFields :fields="fields" :data="data.data" />
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Button, Badge } from 'frappe-ui'
|
||||
import SettingFields from '@/components/SettingFields.vue'
|
||||
import { watch, ref } from 'vue'
|
||||
|
||||
const isDirty = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
},
|
||||
})
|
||||
|
||||
const saveSettings = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Website Settings',
|
||||
name: 'Website Settings',
|
||||
fieldname: values.fields,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const update = () => {
|
||||
let fieldsToSave = {}
|
||||
let imageFields = ['favicon', 'banner_image', 'footer_logo']
|
||||
props.fields.forEach((f) => {
|
||||
if (imageFields.includes(f.name)) {
|
||||
fieldsToSave[f.name] = f.value ? f.value.file_url : null
|
||||
} else {
|
||||
fieldsToSave[f.name] = f.value
|
||||
}
|
||||
})
|
||||
saveSettings.submit({
|
||||
fields: fieldsToSave,
|
||||
})
|
||||
}
|
||||
|
||||
watch(props.data, (newData) => {
|
||||
if (newData && !isDirty.value) {
|
||||
isDirty.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
151
frontend/src/components/Categories.vue
Normal file
151
frontend/src/components/Categories.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ label }}
|
||||
</div>
|
||||
<Button @click="() => showCategoryForm()">
|
||||
<template #icon>
|
||||
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
||||
<X v-else class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showForm"
|
||||
class="flex items-center justify-between my-4 space-x-2"
|
||||
>
|
||||
<FormControl
|
||||
ref="categoryInput"
|
||||
v-model="category"
|
||||
:placeholder="__('Category Name')"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button @click="addCategory()" variant="subtle">
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-scroll">
|
||||
<div class="text-base divide-y">
|
||||
<FormControl
|
||||
:value="cat.category"
|
||||
type="text"
|
||||
v-for="cat in categories.data"
|
||||
class="form-control"
|
||||
@change.stop="(e) => update(cat.name, e.target.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
createListResource,
|
||||
createResource,
|
||||
debounce,
|
||||
} from 'frappe-ui'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const showForm = ref(false)
|
||||
const category = ref(null)
|
||||
const categoryInput = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const categories = createListResource({
|
||||
doctype: 'LMS Category',
|
||||
fields: ['name', 'category'],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const newCategory = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Category',
|
||||
category: category.value,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const addCategory = () => {
|
||||
newCategory.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
categories.reload()
|
||||
category.value = null
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const showCategoryForm = () => {
|
||||
showForm.value = !showForm.value
|
||||
setTimeout(() => {
|
||||
categoryInput.value.$el.querySelector('input').focus()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const updateCategory = createResource({
|
||||
url: 'frappe.client.rename_doc',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Category',
|
||||
old_name: values.name,
|
||||
new_name: values.category,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const update = (name, value) => {
|
||||
updateCategory.submit(
|
||||
{
|
||||
name: name,
|
||||
category: value,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
categories.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.form-control input {
|
||||
padding: 1.25rem 0;
|
||||
border-color: transparent;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.form-control input:focus {
|
||||
outline: transparent;
|
||||
background: white;
|
||||
box-shadow: none;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.form-control input:hover {
|
||||
outline: transparent;
|
||||
background: white;
|
||||
box-shadow: none;
|
||||
border-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -1,33 +1,30 @@
|
||||
<template>
|
||||
<Button
|
||||
v-if="user.data.is_moderator"
|
||||
variant="solid"
|
||||
class="float-right mb-5"
|
||||
@click="openLiveClassModal"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Add Live Class') }}
|
||||
</span>
|
||||
</Button>
|
||||
<div class="text-lg font-semibold mb-5">
|
||||
{{ __('Live Class') }}
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Live Class') }}
|
||||
</div>
|
||||
<Button v-if="user.data.is_moderator" @click="openLiveClassModal">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Add') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
||||
<div
|
||||
v-for="cls in liveClasses.data"
|
||||
class="flex flex-col border rounded-md h-full p-3"
|
||||
class="flex flex-col border rounded-md h-full text-sm text-gray-700 p-3"
|
||||
>
|
||||
<div class="font-semibold text-lg mb-4">
|
||||
<div class="font-semibold text-gray-900 text-lg mb-4">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="leading-5 text-gray-700 text-sm mb-4">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<Calendar class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||
<span class="ml-2">
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
@@ -38,7 +35,7 @@
|
||||
{{ formatTime(cls.time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 mt-auto">
|
||||
<div class="flex items-center space-x-2 text-gray-900 mt-auto">
|
||||
<a
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
@@ -90,7 +87,6 @@ const liveClasses = createListResource({
|
||||
doctype: 'LMS Live Class',
|
||||
filters: {
|
||||
batch_name: props.batch,
|
||||
date: ['>=', new Date()],
|
||||
},
|
||||
fields: [
|
||||
'title',
|
||||
|
||||
@@ -131,10 +131,16 @@ function submitEvaluation(close) {
|
||||
},
|
||||
onError(err) {
|
||||
let message = err.messages?.[0] || err
|
||||
let unavailabilityMessage = message.includes('unavailable')
|
||||
let unavailabilityMessage
|
||||
|
||||
if (typeof message === 'string') {
|
||||
unavailabilityMessage = message?.includes('unavailable')
|
||||
} else {
|
||||
unavailabilityMessage = false
|
||||
}
|
||||
|
||||
createToast({
|
||||
title: unavailabilityMessage ? 'Evaluator is Unavailable' : 'Error',
|
||||
title: unavailabilityMessage ? __('Evaluator is Unavailable') : '',
|
||||
text: message,
|
||||
icon: unavailabilityMessage ? 'alert-circle' : 'x',
|
||||
iconClasses: 'bg-yellow-600 text-white rounded-md p-px',
|
||||
|
||||
@@ -27,8 +27,8 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const file = computed(() => {
|
||||
if (props.type == 'youtube') return '/Youtube.mp4'
|
||||
if (props.type == 'quiz') return '/Quiz.mp4'
|
||||
if (props.type == 'upload') return '/Upload.mp4'
|
||||
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
|
||||
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
|
||||
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold">
|
||||
{{ __('Settings') }}
|
||||
</h1>
|
||||
<div v-for="tab in tabs">
|
||||
<div v-for="tab in tabs" :key="tab.label">
|
||||
<div
|
||||
v-if="!tab.hideLabel"
|
||||
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-gray-600 transition-all duration-300 ease-in-out"
|
||||
@@ -17,6 +17,7 @@
|
||||
<SidebarLink
|
||||
v-for="item in tab.items"
|
||||
:link="item"
|
||||
:key="item.label"
|
||||
class="w-full"
|
||||
:class="
|
||||
activeTab?.label == item.label
|
||||
@@ -30,7 +31,8 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="activeTab && data.doc"
|
||||
class="flex flex-1 flex-col px-10 pt-8"
|
||||
:key="activeTab.label"
|
||||
class="flex flex-1 flex-col px-10 py-8"
|
||||
>
|
||||
<Members
|
||||
v-if="activeTab.label === 'Members'"
|
||||
@@ -38,6 +40,25 @@
|
||||
:description="activeTab.description"
|
||||
v-model:show="show"
|
||||
/>
|
||||
<Categories
|
||||
v-else-if="activeTab.label === 'Categories'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
/>
|
||||
<PaymentSettings
|
||||
v-else-if="activeTab.label === 'Payment Gateway'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
:data="data"
|
||||
:fields="activeTab.fields"
|
||||
/>
|
||||
<BrandSettings
|
||||
v-else-if="activeTab.label === 'Branding'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
:fields="activeTab.fields"
|
||||
:data="branding"
|
||||
/>
|
||||
<SettingDetails
|
||||
v-else
|
||||
:fields="activeTab.fields"
|
||||
@@ -51,15 +72,20 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createDocumentResource } from 'frappe-ui'
|
||||
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import SettingDetails from '../SettingDetails.vue'
|
||||
import SidebarLink from '@/components/SidebarLink.vue'
|
||||
import Members from '@/components/Members.vue'
|
||||
import Categories from '@/components/Categories.vue'
|
||||
import BrandSettings from '@/components/BrandSettings.vue'
|
||||
import PaymentSettings from '@/components/PaymentSettings.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const doctype = ref('LMS Settings')
|
||||
const activeTab = ref(null)
|
||||
const settingsStore = useSettings()
|
||||
|
||||
const data = createDocumentResource({
|
||||
doctype: doctype.value,
|
||||
@@ -69,8 +95,14 @@ const data = createDocumentResource({
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const tabs = computed(() => {
|
||||
let _tabs = [
|
||||
const branding = createResource({
|
||||
url: 'lms.lms.api.get_branding',
|
||||
auto: true,
|
||||
cache: 'brand',
|
||||
})
|
||||
|
||||
const tabsStructure = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Settings',
|
||||
hideLabel: true,
|
||||
@@ -80,6 +112,12 @@ const tabs = computed(() => {
|
||||
description: 'Manage the members of your learning system',
|
||||
icon: 'UserRoundPlus',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Payment Gateway',
|
||||
icon: 'DollarSign',
|
||||
@@ -87,14 +125,10 @@ const tabs = computed(() => {
|
||||
'Configure the payment gateway and other payment related settings',
|
||||
fields: [
|
||||
{
|
||||
label: 'Razorpay Key',
|
||||
name: 'razorpay_key',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
label: 'Razorpay Secret',
|
||||
name: 'razorpay_secret',
|
||||
type: 'password',
|
||||
label: 'Payment Gateway',
|
||||
name: 'payment_gateway',
|
||||
type: 'Link',
|
||||
doctype: 'Payment Gateway',
|
||||
},
|
||||
{
|
||||
label: 'Default Currency',
|
||||
@@ -102,9 +136,6 @@ const tabs = computed(() => {
|
||||
type: 'Link',
|
||||
doctype: 'Currency',
|
||||
},
|
||||
{
|
||||
type: 'Column Break',
|
||||
},
|
||||
{
|
||||
label: 'Apply GST for India',
|
||||
name: 'apply_gst',
|
||||
@@ -128,6 +159,63 @@ const tabs = computed(() => {
|
||||
label: 'Settings',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Categories',
|
||||
description: 'Manage the members of your learning system',
|
||||
icon: 'Network',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Customise',
|
||||
hideLabel: false,
|
||||
items: [
|
||||
{
|
||||
label: 'Branding',
|
||||
icon: 'Blocks',
|
||||
fields: [
|
||||
{
|
||||
label: 'Brand Name',
|
||||
name: 'app_name',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
label: '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',
|
||||
name: 'banner_image',
|
||||
type: 'Upload',
|
||||
},
|
||||
{
|
||||
label: 'Favicon',
|
||||
name: 'favicon',
|
||||
type: 'Upload',
|
||||
},
|
||||
{
|
||||
label: 'Footer Logo',
|
||||
name: 'footer_logo',
|
||||
type: 'Upload',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Sidebar',
|
||||
icon: 'PanelLeftIcon',
|
||||
@@ -168,16 +256,9 @@ const tabs = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Email Templates',
|
||||
icon: 'MailPlus',
|
||||
description: 'Create email templates with the content you want',
|
||||
fields: [
|
||||
{
|
||||
label: 'Batch Confirmation Template',
|
||||
@@ -199,56 +280,19 @@ const tabs = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Signup',
|
||||
icon: 'LogIn',
|
||||
description:
|
||||
'Customize the signup page to inform users about your terms and policies',
|
||||
fields: [
|
||||
{
|
||||
label: 'Show terms of use on signup',
|
||||
name: 'terms_of_use',
|
||||
type: 'checkbox',
|
||||
label: 'Custom Content',
|
||||
name: 'custom_signup_content',
|
||||
type: 'Code',
|
||||
mode: 'htmlmixed',
|
||||
rows: 10,
|
||||
},
|
||||
{
|
||||
label: 'Terms of Use Page',
|
||||
name: 'terms_page',
|
||||
type: 'Link',
|
||||
doctype: 'Web Page',
|
||||
},
|
||||
{
|
||||
label: 'Show privacy policy on signup',
|
||||
name: 'privacy_policy',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Privacy Policy Page',
|
||||
name: 'privacy_policy_page',
|
||||
type: 'Link',
|
||||
doctype: 'Web Page',
|
||||
},
|
||||
{
|
||||
type: 'Column Break',
|
||||
},
|
||||
{
|
||||
label: 'Show cookie policy on signup',
|
||||
name: 'cookie_policy',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Cookie Policy Page',
|
||||
name: 'cookie_policy_page',
|
||||
type: 'Link',
|
||||
doctype: 'Web Page',
|
||||
},
|
||||
{
|
||||
label: 'Ask user category during signup',
|
||||
label: 'Ask user category',
|
||||
name: 'user_category',
|
||||
type: 'checkbox',
|
||||
},
|
||||
@@ -257,23 +301,28 @@ const tabs = computed(() => {
|
||||
],
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
return _tabs.map((tab) => {
|
||||
tab.items = tab.items.filter((item) => {
|
||||
if (item.condition) {
|
||||
return item.condition()
|
||||
}
|
||||
return true
|
||||
})
|
||||
return tab
|
||||
const tabs = computed(() => {
|
||||
return tabsStructure.value.map((tab) => {
|
||||
return {
|
||||
...tab,
|
||||
items: tab.items.filter((item) => {
|
||||
return !item.condition || item.condition()
|
||||
}),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(show, () => {
|
||||
watch(show, async () => {
|
||||
if (show.value) {
|
||||
activeTab.value = tabs.value[0].items[0]
|
||||
const currentTab = await tabs.value
|
||||
.flatMap((tab) => tab.items)
|
||||
.find((item) => item.label === settingsStore.activeTab)
|
||||
activeTab.value = currentTab || tabs.value[0].items[0]
|
||||
} else {
|
||||
activeTab.value = null
|
||||
settingsStore.isSettingsOpen = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
109
frontend/src/components/PaymentSettings.vue
Normal file
109
frontend/src/components/PaymentSettings.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ label }}
|
||||
</div>
|
||||
<!-- <Badge
|
||||
v-if="isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/> -->
|
||||
</div>
|
||||
<div class="overflow-y-scroll">
|
||||
<div class="flex space-x-4">
|
||||
<SettingFields :fields="fields" :data="data.doc" class="w-1/2" />
|
||||
<SettingFields
|
||||
v-if="paymentGateway.data"
|
||||
:fields="paymentGateway.data.fields"
|
||||
:data="paymentGateway.data.data"
|
||||
class="w-1/2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import SettingFields from '@/components/SettingFields.vue'
|
||||
import { createResource, Badge, Button } from 'frappe-ui'
|
||||
import { watch, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const paymentGateway = createResource({
|
||||
url: 'lms.lms.api.get_payment_gateway_details',
|
||||
makeParams(values) {
|
||||
return {
|
||||
payment_gateway: props.data.doc.payment_gateway,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const saveSettings = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
let fields = {}
|
||||
Object.keys(paymentGateway.data.data).forEach((key) => {
|
||||
if (
|
||||
paymentGateway.data.data[key] &&
|
||||
typeof paymentGateway.data.data[key] === 'object'
|
||||
) {
|
||||
fields[key] = paymentGateway.data.data[key].file_url
|
||||
} else {
|
||||
fields[key] = paymentGateway.data.data[key]
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
doctype: paymentGateway.data.doctype,
|
||||
name: paymentGateway.data.docname,
|
||||
fieldname: fields,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
paymentGateway.reload()
|
||||
},
|
||||
})
|
||||
|
||||
const update = () => {
|
||||
props.fields.forEach((f) => {
|
||||
if (f.type != 'Column Break') {
|
||||
props.data.doc[f.name] = f.value
|
||||
}
|
||||
})
|
||||
props.data.save.submit()
|
||||
saveSettings.submit()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data.doc.payment_gateway,
|
||||
() => {
|
||||
paymentGateway.reload()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -1,34 +1,23 @@
|
||||
<template>
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<div class="font-semibold mb-1">
|
||||
{{ __(label) }}
|
||||
<div class="flex itemsc-center justify-between">
|
||||
<div class="font-semibold mb-1">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="data.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between my-5">
|
||||
<div v-for="(column, index) in columns" :key="index">
|
||||
<div class="flex flex-col space-y-5 w-72">
|
||||
<div v-for="field in column">
|
||||
<Link
|
||||
v-if="field.type == 'Link'"
|
||||
v-model="field.value"
|
||||
:doctype="field.doctype"
|
||||
:label="field.label"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
:key="field.name"
|
||||
v-model="field.value"
|
||||
:label="field.label"
|
||||
:type="field.type"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingFields :fields="fields" :data="data.doc" />
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" :loading="data.save.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
@@ -38,9 +27,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { FormControl, Button } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { Button, Badge } from 'frappe-ui'
|
||||
import SettingFields from '@/components/SettingFields.vue'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
@@ -60,37 +48,23 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const columns = computed(() => {
|
||||
const cols = []
|
||||
let currentColumn = []
|
||||
|
||||
props.fields.forEach((field) => {
|
||||
if (field.type === 'Column Break') {
|
||||
if (currentColumn.length > 0) {
|
||||
cols.push(currentColumn)
|
||||
currentColumn = []
|
||||
}
|
||||
} else {
|
||||
if (field.type == 'checkbox') {
|
||||
field.value = props.data.doc[field.name] ? true : false
|
||||
} else {
|
||||
field.value = props.data.doc[field.name]
|
||||
}
|
||||
currentColumn.push(field)
|
||||
}
|
||||
})
|
||||
|
||||
if (currentColumn.length > 0) {
|
||||
cols.push(currentColumn)
|
||||
}
|
||||
|
||||
return cols
|
||||
})
|
||||
|
||||
const update = () => {
|
||||
props.fields.forEach((f) => {
|
||||
props.data.doc[f.name] = f.value
|
||||
if (f.type != 'Column Break') {
|
||||
props.data.doc[f.name] = f.value
|
||||
}
|
||||
})
|
||||
props.data.save.submit()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.CodeMirror pre.CodeMirror-line,
|
||||
.CodeMirror pre.CodeMirror-line-like {
|
||||
font-family: revert;
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
137
frontend/src/components/SettingFields.vue
Normal file
137
frontend/src/components/SettingFields.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div
|
||||
class="my-5"
|
||||
:class="{ 'flex justify-between w-full': columns.length > 1 }"
|
||||
>
|
||||
<div v-for="(column, index) in columns" :key="index">
|
||||
<div
|
||||
class="flex flex-col space-y-5"
|
||||
:class="columns.length > 1 ? 'w-72' : 'w-full'"
|
||||
>
|
||||
<div v-for="field in column">
|
||||
<Link
|
||||
v-if="field.type == 'Link'"
|
||||
v-model="data[field.name]"
|
||||
:doctype="field.doctype"
|
||||
:label="__(field.label)"
|
||||
/>
|
||||
|
||||
<div v-else-if="field.type == 'Code'">
|
||||
<div>
|
||||
{{ __(field.label) }}
|
||||
</div>
|
||||
<Codemirror
|
||||
v-model:value="data[field.name]"
|
||||
:height="200"
|
||||
:options="{
|
||||
mode: field.mode,
|
||||
theme: 'seti',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.type == 'Upload'">
|
||||
<div class="text-sm text-gray-600 mb-1">
|
||||
{{ __(field.label) }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!data[field.name]"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => (data[field.name] = file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading ? `Uploading ${progress}%` : 'Upload an image'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else>
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||
</div>
|
||||
<div class="flex flex-col flex-wrap">
|
||||
<span class="break-all">
|
||||
{{ data[field.name]?.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 mt-1">
|
||||
{{ getFileSize(data[field.name]?.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="data[field.name] = null"
|
||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
v-else
|
||||
:key="field.name"
|
||||
v-model="data[field.name]"
|
||||
:label="__(field.label)"
|
||||
:type="field.type"
|
||||
:rows="field.rows"
|
||||
:options="field.options"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { FormControl, FileUploader, Button } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import { getFileSize, validateFile } from '@/utils'
|
||||
import { X, FileText } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import Codemirror from 'codemirror-editor-vue3'
|
||||
import 'codemirror/theme/seti.css'
|
||||
import 'codemirror/mode/htmlmixed/htmlmixed.js'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const columns = computed(() => {
|
||||
const cols = []
|
||||
let currentColumn = []
|
||||
|
||||
props.fields.forEach((field) => {
|
||||
if (field.type === 'Column Break') {
|
||||
if (currentColumn.length > 0) {
|
||||
cols.push(currentColumn)
|
||||
currentColumn = []
|
||||
}
|
||||
} else {
|
||||
if (field.type == 'checkbox') {
|
||||
field.value = props.data[field.name] ? true : false
|
||||
} else {
|
||||
field.value = props.data[field.name]
|
||||
}
|
||||
currentColumn.push(field)
|
||||
}
|
||||
})
|
||||
|
||||
if (currentColumn.length > 0) {
|
||||
cols.push(currentColumn)
|
||||
}
|
||||
|
||||
return cols
|
||||
})
|
||||
</script>
|
||||
@@ -11,11 +11,11 @@
|
||||
: 'hover:bg-gray-200 px-2 w-52'
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="branding.data?.brand_html"
|
||||
v-html="branding.data?.brand_html"
|
||||
<img
|
||||
v-if="branding.data?.banner_image"
|
||||
:src="branding.data?.banner_image.file_url"
|
||||
class="w-8 h-8 rounded flex-shrink-0"
|
||||
></span>
|
||||
/>
|
||||
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
|
||||
<div
|
||||
class="flex flex-1 flex-col text-left duration-300 ease-in-out"
|
||||
@@ -28,11 +28,10 @@
|
||||
<div class="text-base font-medium text-gray-900 leading-none">
|
||||
<span
|
||||
v-if="
|
||||
branding.data?.brand_name &&
|
||||
branding.data?.brand_name != 'Frappe'
|
||||
branding.data?.app_name && branding.data?.app_name != 'Frappe'
|
||||
"
|
||||
>
|
||||
{{ branding.data?.brand_name }}
|
||||
{{ branding.data?.app_name }}
|
||||
</span>
|
||||
<span v-else> Learning </span>
|
||||
</div>
|
||||
@@ -67,25 +66,20 @@ import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Dropdown } from 'frappe-ui'
|
||||
import Apps from '@/components/Apps.vue'
|
||||
import {
|
||||
ChevronDown,
|
||||
LogIn,
|
||||
LogOut,
|
||||
User,
|
||||
ArrowRightLeft,
|
||||
Settings,
|
||||
} from 'lucide-vue-next'
|
||||
import { ChevronDown, LogIn, LogOut, User, Settings } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { convertToTitleCase } from '../utils'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { ref, markRaw } from 'vue'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { markRaw, watch, ref } from 'vue'
|
||||
import SettingsModal from '@/components/Modals/Settings.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const showSettingsModal = ref(false)
|
||||
const { logout, branding } = sessionStore()
|
||||
let { userResource } = usersStore()
|
||||
const settingsStore = useSettings()
|
||||
let { isLoggedIn } = sessionStore()
|
||||
const showSettingsModal = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
isCollapsed: {
|
||||
@@ -94,6 +88,13 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => settingsStore.isSettingsOpen,
|
||||
(value) => {
|
||||
showSettingsModal.value = value
|
||||
}
|
||||
)
|
||||
|
||||
const userDropdownOptions = [
|
||||
{
|
||||
icon: User,
|
||||
@@ -118,7 +119,7 @@ const userDropdownOptions = [
|
||||
icon: Settings,
|
||||
label: 'Settings',
|
||||
onClick: () => {
|
||||
showSettingsModal.value = true
|
||||
settingsStore.isSettingsOpen = true
|
||||
},
|
||||
condition: () => {
|
||||
return userResource.data?.is_moderator
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
:items="[{ label: __('Batches'), route: { name: 'Batches' } }]"
|
||||
/>
|
||||
<div class="flex space-x-2">
|
||||
<div class="w-40">
|
||||
<div class="w-44">
|
||||
<Select
|
||||
v-if="categories.data?.length"
|
||||
v-model="currentCategory"
|
||||
:options="categories.data"
|
||||
:placeholder="__('Filter')"
|
||||
:placeholder="__('Category')"
|
||||
/>
|
||||
</div>
|
||||
<router-link
|
||||
|
||||
@@ -1,44 +1,50 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs
|
||||
class="h-7"
|
||||
:items="[{ label: __('Billing Details'), route: { name: 'Billing' } }]"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
v-if="access.data?.access && orderSummary.data"
|
||||
class="mt-10 w-1/2 mx-auto"
|
||||
class="pt-5 pb-10 mx-5"
|
||||
>
|
||||
<div class="text-3xl font-bold">
|
||||
{{ __('Billing Details') }}
|
||||
</div>
|
||||
<div class="text-gray-600 mt-1">
|
||||
{{ __('Enter the billing information to complete the payment.') }}
|
||||
</div>
|
||||
<div class="border rounded-md p-5 mt-5">
|
||||
<div class="text-xl font-semibold">
|
||||
{{ __('Summary') }}
|
||||
<!-- <div class="mb-5">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Address') }}
|
||||
</div>
|
||||
<div class="text-gray-600 mt-1">
|
||||
{{ __('Review the details of your purchase.') }}
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
</div> -->
|
||||
<div class="flex flex-col lg:flex-row justify-between">
|
||||
<div
|
||||
class="h-fit bg-gray-100 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 text-sm font-medium lg:w-1/4"
|
||||
>
|
||||
<div class="flex items-center justify-between space-x-2">
|
||||
<div class="text-gray-600">
|
||||
{{ __('Ordered Item') }}
|
||||
</div>
|
||||
<div class="">
|
||||
{{ orderSummary.data.title }}
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'font-semibold text-xl': !orderSummary.data.gst_applied,
|
||||
}"
|
||||
>
|
||||
{{
|
||||
orderSummary.data.gst_applied
|
||||
? orderSummary.data.original_amount_formatted
|
||||
: orderSummary.data.total_amount_formatted
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-if="orderSummary.data.gst_applied"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<div class="text-gray-600">
|
||||
{{ __('Original Amount') }}
|
||||
</div>
|
||||
<div class="">
|
||||
{{ orderSummary.data.original_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="orderSummary.data.gst_applied"
|
||||
class="flex items-center justify-between mt-2"
|
||||
>
|
||||
<div>
|
||||
<div class="text-gray-600">
|
||||
{{ __('GST Amount') }}
|
||||
</div>
|
||||
<div>
|
||||
@@ -46,107 +52,80 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="orderSummary.data.gst_applied"
|
||||
class="flex items-center justify-between mt-2"
|
||||
class="flex items-center justify-between border-t border-gray-400 pt-4 mt-2"
|
||||
>
|
||||
<div>
|
||||
{{ __('Total Amount') }}
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Total') }}
|
||||
</div>
|
||||
<div class="font-semibold text-2xl">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ orderSummary.data.total_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xl font-semibold mt-10">
|
||||
{{ __('Address') }}
|
||||
</div>
|
||||
<div class="text-gray-600 mt-1">
|
||||
{{ __('Specify your billing address correctly.') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5 mt-4">
|
||||
<div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
{{ __('Billing Name') }}
|
||||
</div>
|
||||
<Input type="text" v-model="billingDetails.billing_name" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
{{ __('Address Line 1') }}
|
||||
</div>
|
||||
<Input type="text" v-model="billingDetails.address_line1" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
{{ __('Address Line 2') }}
|
||||
</div>
|
||||
<Input type="text" v-model="billingDetails.address_line2" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
{{ __('City') }}
|
||||
</div>
|
||||
<Input type="text" v-model="billingDetails.city" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
{{ __('State') }}
|
||||
</div>
|
||||
<Input type="text" v-model="billingDetails.state" />
|
||||
<div class="flex-1 lg:mr-10">
|
||||
<div class="mb-5">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Address') }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
{{ __('Country') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
:label="__('Billing Name')"
|
||||
v-model="billingDetails.billing_name"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Address Line 1')"
|
||||
v-model="billingDetails.address_line1"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Address Line 2')"
|
||||
v-model="billingDetails.address_line2"
|
||||
/>
|
||||
<FormControl :label="__('City')" v-model="billingDetails.city" />
|
||||
<FormControl
|
||||
:label="__('State')"
|
||||
v-model="billingDetails.state"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<Link
|
||||
doctype="Country"
|
||||
:value="billingDetails.country"
|
||||
@change="(option) => changeCurrency(option)"
|
||||
:label="__('Country')"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Postal Code')"
|
||||
v-model="billingDetails.pincode"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Phone Number')"
|
||||
v-model="billingDetails.phone"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
{{ __('Postal Code') }}
|
||||
</div>
|
||||
<Input type="text" v-model="billingDetails.pincode" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
{{ __('Phone Number') }}
|
||||
</div>
|
||||
<Input type="text" v-model="billingDetails.phone" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
{{ __('Source') }}
|
||||
</div>
|
||||
<Link
|
||||
doctype="LMS Source"
|
||||
:value="billingDetails.source"
|
||||
@change="(option) => (billingDetails.source = option)"
|
||||
:label="__('Where did you hear about us?')"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="billingDetails.country == 'India'"
|
||||
:label="__('GST Number')"
|
||||
v-model="billingDetails.gstin"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="billingDetails.country == 'India'"
|
||||
:label="__('Pan Number')"
|
||||
v-model="billingDetails.pan"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="billingDetails.country == 'India'" class="mt-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
{{ __('GST Number') }}
|
||||
</div>
|
||||
<Input type="text" v-model="billingDetails.gstin" />
|
||||
</div>
|
||||
<div v-if="billingDetails.country == 'India'" class="mt-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
{{ __('Pan Number') }}
|
||||
</div>
|
||||
<Input type="text" v-model="billingDetails.pan" />
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="solid" class="mt-8" @click="generatePaymentLink()">
|
||||
{{ __('Proceed to Payment') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="solid" class="mt-8" @click="generatePaymentLink()">
|
||||
{{ __('Proceed to Payment') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="access.data?.message">
|
||||
@@ -167,11 +146,18 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Input, Button, createResource } from 'frappe-ui'
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
createResource,
|
||||
FormControl,
|
||||
Breadcrumbs,
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import { reactive, inject, onMounted, ref } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import NotPermitted from '@/components/NotPermitted.vue'
|
||||
import { createToast } from '@/utils/'
|
||||
import { showToast } from '@/utils/'
|
||||
|
||||
const user = inject('$user')
|
||||
|
||||
@@ -202,8 +188,8 @@ const access = createResource({
|
||||
name: props.name,
|
||||
},
|
||||
onSuccess(data) {
|
||||
orderSummary.submit()
|
||||
setBillingDetails(data.address)
|
||||
orderSummary.submit()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -224,84 +210,49 @@ const orderSummary = createResource({
|
||||
const billingDetails = reactive({})
|
||||
|
||||
const setBillingDetails = (data) => {
|
||||
billingDetails.billing_name = data.billing_name || ''
|
||||
billingDetails.address_line1 = data.address_line1 || ''
|
||||
billingDetails.address_line2 = data.address_line2 || ''
|
||||
billingDetails.city = data.city || ''
|
||||
billingDetails.state = data.state || ''
|
||||
billingDetails.country = data.country || ''
|
||||
billingDetails.pincode = data.pincode || ''
|
||||
billingDetails.phone = data.phone || ''
|
||||
billingDetails.source = data.source || ''
|
||||
billingDetails.gstin = data.gstin || ''
|
||||
billingDetails.pan = data.pan || ''
|
||||
billingDetails.billing_name = data?.billing_name || ''
|
||||
billingDetails.address_line1 = data?.address_line1 || ''
|
||||
billingDetails.address_line2 = data?.address_line2 || ''
|
||||
billingDetails.city = data?.city || ''
|
||||
billingDetails.state = data?.state || ''
|
||||
billingDetails.country = data?.country || ''
|
||||
billingDetails.pincode = data?.pincode || ''
|
||||
billingDetails.phone = data?.phone || ''
|
||||
billingDetails.source = data?.source || ''
|
||||
billingDetails.gstin = data?.gstin || ''
|
||||
billingDetails.pan = data?.pan || ''
|
||||
}
|
||||
|
||||
const paymentOptions = createResource({
|
||||
url: 'lms.lms.utils.get_payment_options',
|
||||
const paymentLink = createResource({
|
||||
url: 'lms.lms.payments.get_payment_link',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
||||
docname: props.name,
|
||||
phone: billingDetails.phone,
|
||||
country: billingDetails.country,
|
||||
title: orderSummary.data.title,
|
||||
amount: orderSummary.data.original_amount,
|
||||
total_amount: orderSummary.data.amount,
|
||||
currency: orderSummary.data.currency,
|
||||
address: billingDetails,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const generatePaymentLink = () => {
|
||||
paymentOptions.submit(
|
||||
paymentLink.submit(
|
||||
{},
|
||||
{
|
||||
validate(params) {
|
||||
validate() {
|
||||
if (!billingDetails.source) {
|
||||
return __('Please let us know where you heard about us from.')
|
||||
}
|
||||
return validateAddress()
|
||||
},
|
||||
onSuccess(data) {
|
||||
data.handler = (response) => {
|
||||
let doctype = props.type == 'course' ? 'LMS Course' : 'LMS Batch'
|
||||
let docname = props.name
|
||||
handleSuccess(response, doctype, docname, data.order_id)
|
||||
}
|
||||
let rzp1 = new Razorpay(data)
|
||||
rzp1.open()
|
||||
window.location.href = data
|
||||
},
|
||||
onError(err) {
|
||||
showError(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const paymentResource = createResource({
|
||||
url: 'lms.lms.utils.verify_payment',
|
||||
makeParams(values) {
|
||||
return {
|
||||
response: values.response,
|
||||
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
||||
docname: props.name,
|
||||
address: billingDetails,
|
||||
order_id: values.orderId,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const handleSuccess = (response, doctype, docname, orderId) => {
|
||||
paymentResource.submit(
|
||||
{
|
||||
response: response,
|
||||
orderId: orderId,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
createToast({
|
||||
title: 'Success',
|
||||
text: 'Payment Successful',
|
||||
icon: 'check',
|
||||
iconClasses: 'bg-green-600 text-white rounded-md p-px',
|
||||
})
|
||||
setTimeout(() => {
|
||||
window.location.href = data
|
||||
}, 3000)
|
||||
showToast(__('Error'), err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -109,6 +109,14 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-1/2 mb-4">
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
v-model="course.category"
|
||||
:label="__('Category')"
|
||||
:onCreate="(value, close) => openSettings(close)"
|
||||
/>
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="User"
|
||||
@@ -221,18 +229,20 @@ import {
|
||||
showToast,
|
||||
getFileSize,
|
||||
updateDocumentTitle,
|
||||
} from '../utils'
|
||||
} from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
const user = inject('$user')
|
||||
const newTag = ref('')
|
||||
const router = useRouter()
|
||||
const instructors = ref([])
|
||||
const settingsStore = useSettings()
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
@@ -463,6 +473,12 @@ const removeImage = () => {
|
||||
course.course_image = null
|
||||
}
|
||||
|
||||
const openSettings = (close) => {
|
||||
close()
|
||||
settingsStore.activeTab = 'Categories'
|
||||
settingsStore.isSettingsOpen = true
|
||||
}
|
||||
|
||||
const check_permission = () => {
|
||||
let user_is_instructor = false
|
||||
if (user.data?.is_moderator) return
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
|
||||
/>
|
||||
<div class="flex space-x-2 justify-end">
|
||||
<div class="w-44">
|
||||
<FormControl
|
||||
v-if="categories.data?.length"
|
||||
type="select"
|
||||
v-model="currentCategory"
|
||||
:options="categories.data"
|
||||
:placeholder="__('Category')"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<FormControl
|
||||
type="text"
|
||||
@@ -119,11 +128,19 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import { Plus, Search } from 'lucide-vue-next'
|
||||
import { ref, computed, inject } from 'vue'
|
||||
import { ref, computed, inject, onMounted, watch } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const searchQuery = ref('')
|
||||
const currentCategory = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
if (queries.has('category')) {
|
||||
currentCategory.value = queries.get('category')
|
||||
}
|
||||
})
|
||||
|
||||
const courses = createResource({
|
||||
url: 'lms.lms.utils.get_courses',
|
||||
@@ -168,18 +185,57 @@ const addToTabs = (label) => {
|
||||
}
|
||||
|
||||
const getCourses = (type) => {
|
||||
let courseList = courses.data[type]
|
||||
if (searchQuery.value) {
|
||||
let query = searchQuery.value.toLowerCase()
|
||||
return courses.data[type].filter(
|
||||
courseList = courseList.filter(
|
||||
(course) =>
|
||||
course.title.toLowerCase().includes(query) ||
|
||||
course.short_introduction.toLowerCase().includes(query) ||
|
||||
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
|
||||
)
|
||||
}
|
||||
return courses.data[type]
|
||||
if (currentCategory.value && currentCategory.value != '') {
|
||||
courseList = courseList.filter(
|
||||
(course) => course.category == currentCategory.value
|
||||
)
|
||||
}
|
||||
return courseList
|
||||
}
|
||||
|
||||
const categories = createResource({
|
||||
url: 'lms.lms.api.get_categories',
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: 'LMS Course',
|
||||
filters: {
|
||||
published: 1,
|
||||
},
|
||||
}
|
||||
},
|
||||
cache: ['courseCategories'],
|
||||
auto: true,
|
||||
transform(data) {
|
||||
data.unshift({
|
||||
label: '',
|
||||
value: null,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => currentCategory.value,
|
||||
() => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
if (currentCategory.value) {
|
||||
queries.set('category', currentCategory.value)
|
||||
} else {
|
||||
queries.delete('category')
|
||||
}
|
||||
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
|
||||
}
|
||||
)
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: 'Courses',
|
||||
|
||||
@@ -244,7 +244,10 @@ const lesson = createResource({
|
||||
onSuccess(data) {
|
||||
lessonProgress.value = data.membership?.progress
|
||||
if (data.content) editor.value = renderEditor('editor', data.content)
|
||||
if (JSON.parse(data.instructor_content)?.blocks?.length > 1)
|
||||
if (
|
||||
data.instructor_content &&
|
||||
JSON.parse(data.instructor_content)?.blocks?.length > 1
|
||||
)
|
||||
instructorEditor.value = renderEditor(
|
||||
'instructor-content',
|
||||
data.instructor_content
|
||||
|
||||
@@ -142,7 +142,7 @@ const shareOnSocial = (badge, medium) => {
|
||||
const summary = `I am happy to announce that I earned the ${
|
||||
badge.badge
|
||||
} badge on ${dayjs(badge.issued_on).format('DD MMM YYYY')} at ${
|
||||
branding.data?.brand_name
|
||||
branding.data?.app_name
|
||||
}.`
|
||||
|
||||
if (medium == 'LinkedIn')
|
||||
|
||||
@@ -17,7 +17,7 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
}
|
||||
|
||||
let user = ref(sessionUser())
|
||||
if (user) {
|
||||
if (user.value) {
|
||||
allUsers.reload()
|
||||
}
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
|
||||
12
frontend/src/stores/settings.js
Normal file
12
frontend/src/stores/settings.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useSettings = defineStore('settings', () => {
|
||||
const isSettingsOpen = ref(false)
|
||||
const activeTab = ref(null)
|
||||
|
||||
return {
|
||||
isSettingsOpen,
|
||||
activeTab,
|
||||
}
|
||||
})
|
||||
@@ -499,3 +499,10 @@ export function singularize(word) {
|
||||
(r) => endings[r]
|
||||
)
|
||||
}
|
||||
|
||||
export const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
|
||||
return __('Only image file is allowed.')
|
||||
}
|
||||
}
|
||||
|
||||
2183
frontend/yarn.lock
Normal file
2183
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
||||
__version__ = "2.5.0"
|
||||
__version__ = "2.7.0"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
from frappe import _
|
||||
|
||||
|
||||
def get_data():
|
||||
return [
|
||||
{
|
||||
"module_name": "Community",
|
||||
"color": "grey",
|
||||
"icon": "octicon octicon-file-directory",
|
||||
"type": "module",
|
||||
"label": _("Community"),
|
||||
}
|
||||
]
|
||||
@@ -1,12 +0,0 @@
|
||||
"""
|
||||
Configuration for docs
|
||||
"""
|
||||
|
||||
# source_link = "https://github.com/[org_name]/community"
|
||||
# docs_base_url = "https://[org_name].github.io/community"
|
||||
# headline = "App that does everything"
|
||||
# sub_heading = "Yes, you got that right the first time, everything"
|
||||
|
||||
|
||||
def get_context(context):
|
||||
context.brand_html = "Community"
|
||||
File diff suppressed because it is too large
Load Diff
51
lms/fixtures/lms_category.json
Normal file
51
lms/fixtures/lms_category.json
Normal file
@@ -0,0 +1,51 @@
|
||||
[
|
||||
{
|
||||
"category": "Web Development",
|
||||
"docstatus": 0,
|
||||
"doctype": "LMS Category",
|
||||
"modified": "2024-09-20 12:58:16.841571",
|
||||
"name": "Web Development"
|
||||
},
|
||||
{
|
||||
"category": "Business",
|
||||
"docstatus": 0,
|
||||
"doctype": "LMS Category",
|
||||
"modified": "2024-09-20 12:58:32.304850",
|
||||
"name": "Business"
|
||||
},
|
||||
{
|
||||
"category": "Design",
|
||||
"docstatus": 0,
|
||||
"doctype": "LMS Category",
|
||||
"modified": "2024-09-20 12:59:12.621022",
|
||||
"name": "Design"
|
||||
},
|
||||
{
|
||||
"category": "Personal Development",
|
||||
"docstatus": 0,
|
||||
"doctype": "LMS Category",
|
||||
"modified": "2024-09-20 12:59:19.287404",
|
||||
"name": "Personal Development"
|
||||
},
|
||||
{
|
||||
"category": "Finance",
|
||||
"docstatus": 0,
|
||||
"doctype": "LMS Category",
|
||||
"modified": "2024-09-20 12:58:28.579714",
|
||||
"name": "Finance"
|
||||
},
|
||||
{
|
||||
"category": "Frontend",
|
||||
"docstatus": 0,
|
||||
"doctype": "LMS Category",
|
||||
"modified": "2024-05-08 14:05:16.979275",
|
||||
"name": "Frontend"
|
||||
},
|
||||
{
|
||||
"category": "Framework",
|
||||
"docstatus": 0,
|
||||
"doctype": "LMS Category",
|
||||
"modified": "2023-06-15 18:01:41.598282",
|
||||
"name": "Framework"
|
||||
}
|
||||
]
|
||||
@@ -115,7 +115,7 @@ scheduler_events = {
|
||||
"daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"],
|
||||
}
|
||||
|
||||
fixtures = ["Custom Field", "Function", "Industry"]
|
||||
fixtures = ["Custom Field", "Function", "Industry", "LMS Category"]
|
||||
|
||||
# Testing
|
||||
# -------
|
||||
|
||||
@@ -289,11 +289,13 @@ def get_file_info(file_url):
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_branding():
|
||||
"""Get branding details."""
|
||||
return {
|
||||
"brand_name": frappe.db.get_single_value("Website Settings", "app_name"),
|
||||
"brand_html": frappe.db.get_single_value("Website Settings", "brand_html"),
|
||||
"favicon": frappe.db.get_single_value("Website Settings", "favicon"),
|
||||
}
|
||||
website_settings = frappe.get_single("Website Settings")
|
||||
image_fields = ["banner_image", "footer_logo", "favicon"]
|
||||
|
||||
for field in image_fields:
|
||||
website_settings.update({field: get_file_info(website_settings.get(field))})
|
||||
|
||||
return website_settings
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -706,3 +708,49 @@ def delete_documents(doctype, documents):
|
||||
frappe.only_for("Moderator")
|
||||
for doc in documents:
|
||||
frappe.delete_doc(doctype, doc)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_gateway_details(payment_gateway):
|
||||
fields = []
|
||||
gateway = frappe.get_doc("Payment Gateway", payment_gateway)
|
||||
|
||||
if gateway.gateway_controller is None:
|
||||
try:
|
||||
data = frappe.get_doc(f"{payment_gateway} Settings").as_dict()
|
||||
meta = frappe.get_meta(f"{payment_gateway} Settings").fields
|
||||
doctype = f"{payment_gateway} Settings"
|
||||
docname = f"{payment_gateway} Settings"
|
||||
except Exception:
|
||||
frappe.throw(_("{0} Settings not found").format(payment_gateway))
|
||||
else:
|
||||
try:
|
||||
data = frappe.get_doc(gateway.gateway_settings, gateway.gateway_controller).as_dict()
|
||||
meta = frappe.get_meta(gateway.gateway_settings).fields
|
||||
doctype = gateway.gateway_settings
|
||||
docname = gateway.gateway_controller
|
||||
except Exception:
|
||||
frappe.throw(_("{0} Settings not found").format(payment_gateway))
|
||||
|
||||
for row in meta:
|
||||
if row.fieldtype not in ["Column Break", "Section Break"]:
|
||||
if row.fieldtype in ["Attach", "Attach Image"]:
|
||||
fieldtype = "Upload"
|
||||
data[row.fieldname] = get_file_info(data.get(row.fieldname))
|
||||
else:
|
||||
fieldtype = row.fieldtype
|
||||
|
||||
fields.append(
|
||||
{
|
||||
"label": row.label,
|
||||
"name": row.fieldname,
|
||||
"type": fieldtype,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"fields": fields,
|
||||
"data": data,
|
||||
"doctype": doctype,
|
||||
"docname": docname,
|
||||
}
|
||||
|
||||
@@ -1,148 +1,4 @@
|
||||
// Copyright (c) 2021, FOSS United and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("Course Lesson", {
|
||||
setup: function (frm) {
|
||||
frm.trigger("setup_help");
|
||||
},
|
||||
setup_help(frm) {
|
||||
let quiz_link = `<a href="/app/lms-quiz"> ${__("Quiz List")} </a>`;
|
||||
let exercise_link = `<a href="/app/lms-exercise"> ${__(
|
||||
"Exercise List"
|
||||
)} </a>`;
|
||||
let file_link = `<a href="/app/file"> ${__("File DocType")} </a>`;
|
||||
|
||||
frm.get_field("help").html(`
|
||||
<p>${__(
|
||||
"You can add some more additional content to the lesson using a special syntax. The table below mentions all types of dynamic content that you can add to the lessons and the syntax for the same."
|
||||
)}</p>
|
||||
<table class="table">
|
||||
<tr style="background-color: var(--fg-hover-color); font-weight: bold">
|
||||
<th style="width: 20%;">
|
||||
${__("Content Type")}
|
||||
</th>
|
||||
<th style="width: 40%;">
|
||||
${__("Syntax")}
|
||||
</th>
|
||||
<th>
|
||||
${__("Description")}
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
${__("YouTube Video")}
|
||||
</td>
|
||||
<td>
|
||||
{{ YouTubeVideo("unique_embed_id") }}
|
||||
</td>
|
||||
<td>
|
||||
<span>
|
||||
${__(
|
||||
"Copy and paste the syntax in the editor. Replace 'embed_src' with the embed source that YouTube provides. To get the source, follow the steps mentioned below."
|
||||
)}
|
||||
</span>
|
||||
<ul class="p-4">
|
||||
<li>
|
||||
${__("Upload the video on youtube.")}
|
||||
</li>
|
||||
<li>
|
||||
${__(
|
||||
"When you share a youtube video, it shows an option called Embed."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
${__(
|
||||
"On clicking it, it provides an iframe. Copy the source (src) of the iframe and paste it here."
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
${__("Quiz")}
|
||||
</td>
|
||||
<td>
|
||||
{{ Quiz("lms_quiz_id") }}
|
||||
</td>
|
||||
<td>
|
||||
${__(
|
||||
"Copy and paste the syntax in the editor. Replace 'lms_quiz_id' with the ID of the Quiz you want to add. You can get the ID of the quiz from the {0}.",
|
||||
[quiz_link]
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
${__("Video")}
|
||||
</td>
|
||||
<td>
|
||||
{{ Video("url_of_source") }}
|
||||
</td>
|
||||
<td>
|
||||
${__(
|
||||
"Upload a video from your local machine to the {0}. Copy and paste this syntax in the editor. Replace 'url_of_source' with the File URL field of the document you created in the File DocType.",
|
||||
[file_link]
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
${"Exercise"}
|
||||
</td>
|
||||
<td>
|
||||
{{ Exercise("exercise_id") }}
|
||||
</td>
|
||||
<td>
|
||||
${__(
|
||||
"Copy and paste the syntax in the editor. Replace 'exercise_id' with the ID of the Exercise you want to add. You can get the ID of the exercise from the {0}.",
|
||||
[exercise_link]
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
${__("Assignment")}
|
||||
</td>
|
||||
<td>
|
||||
{{ Assignment("id-filetype") }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<hr>
|
||||
<table class="table">
|
||||
<tr style="background-color: var(--fg-hover-color); font-weight: bold">
|
||||
<th style="width: 90%">
|
||||
${__("Supported File Types for Assignment")}
|
||||
</th>
|
||||
<th>
|
||||
${__("Syntax")}
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
.doc, .docx, .xml
|
||||
<td>
|
||||
${__("Document")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
.pdf
|
||||
</td>
|
||||
<td>
|
||||
${__("PDF")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
.png, .jpg, .jpeg
|
||||
</td>
|
||||
<td>
|
||||
${__("Image")}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`);
|
||||
},
|
||||
});
|
||||
frappe.ui.form.on("Course Lesson", {});
|
||||
|
||||
@@ -126,7 +126,7 @@ def save_progress(lesson, course):
|
||||
|
||||
def capture_progress_for_analytics(progress, course):
|
||||
if progress in [25, 50, 75, 100]:
|
||||
capture("course_progress", "lms", {"course": course, "progress": progress})
|
||||
capture("course_progress", "lms", properties={"course": course, "progress": progress})
|
||||
|
||||
|
||||
def get_quiz_progress(lesson):
|
||||
|
||||
@@ -15,6 +15,7 @@ from lms.lms.utils import (
|
||||
get_lesson_url,
|
||||
get_quiz_details,
|
||||
get_assignment_details,
|
||||
update_payment_record,
|
||||
)
|
||||
from frappe.email.doctype.email_template.email_template import get_email_template
|
||||
|
||||
@@ -26,6 +27,7 @@ class LMSBatch(Document):
|
||||
self.validate_batch_end_date()
|
||||
self.validate_duplicate_courses()
|
||||
self.validate_duplicate_students()
|
||||
self.validate_payments_app()
|
||||
self.validate_duplicate_assessments()
|
||||
self.validate_membership()
|
||||
self.validate_timetable()
|
||||
@@ -55,6 +57,12 @@ class LMSBatch(Document):
|
||||
_("Course {0} has already been added to this batch.").format(frappe.bold(title))
|
||||
)
|
||||
|
||||
def validate_payments_app(self):
|
||||
if self.paid_batch:
|
||||
installed_apps = frappe.get_installed_apps()
|
||||
if "payments" not in installed_apps:
|
||||
frappe.throw(_("Please install the Payments app to create a paid batches."))
|
||||
|
||||
def validate_duplicate_assessments(self):
|
||||
assessments = [row.assessment_name for row in self.assessment]
|
||||
for assessment in self.assessment:
|
||||
@@ -164,23 +172,9 @@ class LMSBatch(Document):
|
||||
_("Row #{0} Date cannot be outside the batch duration.").format(schedule.idx)
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def remove_student(student, batch_name):
|
||||
frappe.only_for("Moderator")
|
||||
frappe.db.delete("Batch Student", {"student": student, "parent": batch_name})
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def remove_course(course, parent):
|
||||
frappe.only_for("Moderator")
|
||||
frappe.db.delete("Batch Course", {"course": course, "parent": parent})
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def remove_assessment(assessment, parent):
|
||||
frappe.only_for("Moderator")
|
||||
frappe.db.delete("LMS Assessment", {"assessment_name": assessment, "parent": parent})
|
||||
def on_payment_authorized(self, payment_status):
|
||||
if payment_status in ["Authorized", "Completed"]:
|
||||
update_payment_record("LMS Batch", self.name)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -15,12 +15,13 @@
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Category",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2023-06-15 15:14:11.341961",
|
||||
"modified": "2024-09-23 19:33:49.593950",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Category",
|
||||
@@ -55,5 +56,6 @@
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "category"
|
||||
"title_field": "category",
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -13,6 +13,7 @@ from frappe.utils import (
|
||||
get_datetime,
|
||||
nowtime,
|
||||
get_time,
|
||||
get_fullname,
|
||||
)
|
||||
from lms.lms.utils import get_evaluator
|
||||
import json
|
||||
@@ -32,6 +33,7 @@ class LMSCertificateRequest(Document):
|
||||
def set_evaluator(self):
|
||||
if not self.evaluator:
|
||||
self.evaluator = get_evaluator(self.course, self.batch_name)
|
||||
self.evaluator_name = get_fullname(self.evaluator)
|
||||
|
||||
def validate_unavailability(self):
|
||||
if self.evaluator:
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
"field_order": [
|
||||
"title",
|
||||
"video_link",
|
||||
"image",
|
||||
"column_break_3",
|
||||
"instructors",
|
||||
"tags",
|
||||
"column_break_htgn",
|
||||
"image",
|
||||
"category",
|
||||
"status",
|
||||
"section_break_7",
|
||||
"published",
|
||||
@@ -237,6 +239,16 @@
|
||||
"fieldname": "certification_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Certification"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_htgn",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "category",
|
||||
"fieldtype": "Link",
|
||||
"label": "Category",
|
||||
"options": "LMS Category"
|
||||
}
|
||||
],
|
||||
"is_published_field": "published",
|
||||
@@ -263,7 +275,7 @@
|
||||
}
|
||||
],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2024-07-12 13:54:40.474097",
|
||||
"modified": "2024-09-21 10:23:58.633912",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Course",
|
||||
|
||||
@@ -8,7 +8,7 @@ from frappe.model.document import Document
|
||||
from frappe.utils import cint, today
|
||||
from frappe.utils.telemetry import capture
|
||||
from lms.lms.utils import get_chapters, can_create_courses
|
||||
from ...utils import generate_slug, validate_image
|
||||
from ...utils import generate_slug, validate_image, update_payment_record
|
||||
from frappe import _
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ class LMSCourse(Document):
|
||||
self.validate_instructors()
|
||||
self.validate_video_link()
|
||||
self.validate_status()
|
||||
self.validate_payments_app()
|
||||
self.image = validate_image(self.image)
|
||||
|
||||
def validate_published(self):
|
||||
@@ -44,10 +45,20 @@ class LMSCourse(Document):
|
||||
if self.published:
|
||||
self.status = "Approved"
|
||||
|
||||
def validate_payments_app(self):
|
||||
if self.paid_course:
|
||||
installed_apps = frappe.get_installed_apps()
|
||||
if "payments" not in installed_apps:
|
||||
frappe.throw(_("Please install the Payments app to create a paid courses."))
|
||||
|
||||
def on_update(self):
|
||||
if not self.upcoming and self.has_value_changed("upcoming"):
|
||||
self.send_email_to_interested_users()
|
||||
|
||||
def on_payment_authorized(self, payment_status):
|
||||
if payment_status in ["Authorized", "Completed"]:
|
||||
update_payment_record("LMS Course", self.name)
|
||||
|
||||
def send_email_to_interested_users(self):
|
||||
interested_users = frappe.get_all(
|
||||
"LMS Course Interest", {"course": self.name}, ["name", "user"]
|
||||
|
||||
@@ -2,6 +2,28 @@
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("LMS Settings", {
|
||||
// refresh: function(frm) {
|
||||
// }
|
||||
setup: function (frm) {
|
||||
frappe.call({
|
||||
method: "lms.lms.doctype.lms_settings.lms_settings.check_payments_app",
|
||||
callback: (data) => {
|
||||
if (!data.message) {
|
||||
frm.set_df_property("payment_section", "hidden", 1);
|
||||
frm.trigger("set_no_payments_app_html");
|
||||
} else {
|
||||
frm.set_df_property("no_payments_app", "hidden", 1);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
set_no_payments_app_html(frm) {
|
||||
frm.get_field("payments_app_is_not_installed").html(`
|
||||
<div class="alert alert-warning">
|
||||
Please install the
|
||||
<a target="_blank" style="color: var(--alert-text-warning); background: var(--alert-bg-warning);" href="https://frappecloud.com/marketplace/apps/payments">
|
||||
Payments app
|
||||
</a>
|
||||
to enable payment gateway.
|
||||
`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -23,15 +23,9 @@
|
||||
"show_emails",
|
||||
"signup_settings_tab",
|
||||
"signup_settings_section",
|
||||
"terms_of_use",
|
||||
"terms_page",
|
||||
"user_category",
|
||||
"column_break_9",
|
||||
"privacy_policy",
|
||||
"privacy_policy_page",
|
||||
"column_break_12",
|
||||
"cookie_policy",
|
||||
"cookie_policy_page",
|
||||
"custom_signup_content",
|
||||
"user_category",
|
||||
"sidebar_tab",
|
||||
"items_in_sidebar_section",
|
||||
"courses",
|
||||
@@ -48,14 +42,15 @@
|
||||
"mentor_request_status_update",
|
||||
"payment_settings_tab",
|
||||
"payment_section",
|
||||
"razorpay_key",
|
||||
"razorpay_secret",
|
||||
"apply_gst",
|
||||
"column_break_cfcv",
|
||||
"payment_gateway",
|
||||
"default_currency",
|
||||
"exception_country",
|
||||
"column_break_cfcv",
|
||||
"apply_gst",
|
||||
"show_usd_equivalent",
|
||||
"apply_rounding",
|
||||
"exception_country",
|
||||
"no_payments_app",
|
||||
"payments_app_is_not_installed",
|
||||
"email_templates_tab",
|
||||
"certification_template",
|
||||
"batch_confirmation_template",
|
||||
@@ -92,60 +87,14 @@
|
||||
"fieldtype": "Column Break",
|
||||
"label": "Show Tab in Batch"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "terms_of_use",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Terms of Use on Signup"
|
||||
},
|
||||
{
|
||||
"depends_on": "terms_of_use",
|
||||
"fieldname": "terms_page",
|
||||
"fieldtype": "Link",
|
||||
"label": "Terms of Use Page",
|
||||
"mandatory_depends_on": "terms_of_use",
|
||||
"options": "Web Page"
|
||||
},
|
||||
{
|
||||
"fieldname": "signup_settings_section",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "privacy_policy",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Privacy Policy on Signup"
|
||||
},
|
||||
{
|
||||
"depends_on": "privacy_policy",
|
||||
"fieldname": "privacy_policy_page",
|
||||
"fieldtype": "Link",
|
||||
"label": "Privacy Policy Page",
|
||||
"mandatory_depends_on": "privacy_policy",
|
||||
"options": "Web Page"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_12",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_9",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "cookie_policy",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Cookie Policy on Signup"
|
||||
},
|
||||
{
|
||||
"depends_on": "cookie_policy",
|
||||
"fieldname": "cookie_policy_page",
|
||||
"fieldtype": "Link",
|
||||
"label": "Cookie Policy Page",
|
||||
"mandatory_depends_on": "cookie_policy",
|
||||
"options": "Web Page"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "user_category",
|
||||
@@ -199,16 +148,6 @@
|
||||
"fieldname": "column_break_cfcv",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "razorpay_key",
|
||||
"fieldtype": "Data",
|
||||
"label": "Razorpay Key"
|
||||
},
|
||||
{
|
||||
"fieldname": "razorpay_secret",
|
||||
"fieldtype": "Password",
|
||||
"label": "Razorpay Secret"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "apply_gst",
|
||||
@@ -225,7 +164,7 @@
|
||||
"depends_on": "show_usd_equivalent",
|
||||
"fieldname": "exception_country",
|
||||
"fieldtype": "Table MultiSelect",
|
||||
"label": "Maintain Original Currency",
|
||||
"label": "Primary Countries",
|
||||
"options": "Payment Country"
|
||||
},
|
||||
{
|
||||
@@ -378,12 +317,31 @@
|
||||
"fieldtype": "Table",
|
||||
"label": "Sidebar Items",
|
||||
"options": "LMS Sidebar Item"
|
||||
},
|
||||
{
|
||||
"fieldname": "custom_signup_content",
|
||||
"fieldtype": "HTML Editor",
|
||||
"label": "Custom Signup Content"
|
||||
},
|
||||
{
|
||||
"fieldname": "payment_gateway",
|
||||
"fieldtype": "Data",
|
||||
"label": "Payment Gateway"
|
||||
},
|
||||
{
|
||||
"fieldname": "no_payments_app",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "payments_app_is_not_installed",
|
||||
"fieldtype": "HTML",
|
||||
"label": "Payments app is not installed"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2024-08-13 19:02:58.714080",
|
||||
"modified": "2024-10-01 12:15:49.800242",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Settings",
|
||||
|
||||
@@ -39,3 +39,32 @@ class LMSSettings(Document):
|
||||
frappe.bold("Course Evaluator"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def check_payments_app():
|
||||
installed_apps = frappe.get_installed_apps()
|
||||
if "payments" not in installed_apps:
|
||||
return False
|
||||
else:
|
||||
filters = {
|
||||
"doctype_or_field": "DocField",
|
||||
"doc_type": "LMS Settings",
|
||||
"field_name": "payment_gateway",
|
||||
}
|
||||
if frappe.db.exists("Property Setter", filters):
|
||||
return True
|
||||
|
||||
link_property = frappe.new_doc("Property Setter")
|
||||
link_property.update(filters)
|
||||
link_property.property = "fieldtype"
|
||||
link_property.value = "Link"
|
||||
link_property.save()
|
||||
|
||||
options_property = frappe.new_doc("Property Setter")
|
||||
options_property.update(filters)
|
||||
options_property.property = "options"
|
||||
options_property.value = "Payment Gateway"
|
||||
options_property.save()
|
||||
|
||||
return True
|
||||
|
||||
92
lms/lms/payments.py
Normal file
92
lms/lms/payments.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import frappe
|
||||
from payments.utils import get_payment_gateway_controller
|
||||
|
||||
|
||||
def get_payment_gateway():
|
||||
return frappe.db.get_single_value("LMS Settings", "payment_gateway")
|
||||
|
||||
|
||||
def get_controller(payment_gateway):
|
||||
return get_payment_gateway_controller(payment_gateway)
|
||||
|
||||
|
||||
def validate_currency(payment_gateway, currency):
|
||||
controller = get_controller(payment_gateway)
|
||||
controller().validate_transaction_currency(currency)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_link(doctype, docname, title, amount, total_amount, currency, address):
|
||||
payment_gateway = get_payment_gateway()
|
||||
address = frappe._dict(address)
|
||||
amount_with_gst = total_amount if total_amount != amount else 0
|
||||
|
||||
payment = record_payment(address, doctype, docname, amount, currency, amount_with_gst)
|
||||
controller = get_controller(payment_gateway)
|
||||
|
||||
if doctype == "LMS Course":
|
||||
redirect_to = f"/lms/courses/{docname}/learn/1-1"
|
||||
elif doctype == "LMS Batch":
|
||||
redirect_to = f"/lms/batches/{docname}"
|
||||
|
||||
payment_details = {
|
||||
"amount": total_amount,
|
||||
"title": f"Payment for {doctype} {title} {docname}",
|
||||
"description": f"{address.billing_name}'s payment for {title}",
|
||||
"reference_doctype": doctype,
|
||||
"reference_docname": docname,
|
||||
"payer_email": frappe.session.user,
|
||||
"payer_name": address.billing_name,
|
||||
"currency": currency,
|
||||
"payment_gateway": payment_gateway,
|
||||
"redirect_to": redirect_to,
|
||||
"payment": payment.name,
|
||||
}
|
||||
url = controller.get_payment_url(**payment_details)
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def record_payment(address, doctype, docname, amount, currency, amount_with_gst=0):
|
||||
address = frappe._dict(address)
|
||||
address_name = save_address(address)
|
||||
|
||||
payment_doc = frappe.new_doc("LMS Payment")
|
||||
payment_doc.update(
|
||||
{
|
||||
"member": frappe.session.user,
|
||||
"billing_name": address.billing_name,
|
||||
"address": address_name,
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"amount_with_gst": amount_with_gst,
|
||||
"gstin": address.gstin,
|
||||
"pan": address.pan,
|
||||
"source": address.source,
|
||||
"payment_for_document_type": doctype,
|
||||
"payment_for_document": docname,
|
||||
}
|
||||
)
|
||||
payment_doc.save(ignore_permissions=True)
|
||||
return payment_doc
|
||||
|
||||
|
||||
def save_address(address):
|
||||
filters = {"email_id": frappe.session.user}
|
||||
exists = frappe.db.exists("Address", filters)
|
||||
if exists:
|
||||
address_doc = frappe.get_last_doc("Address", filters=filters)
|
||||
else:
|
||||
address_doc = frappe.new_doc("Address")
|
||||
|
||||
address_doc.update(address)
|
||||
address_doc.update(
|
||||
{
|
||||
"address_title": frappe.db.get_value("User", frappe.session.user, "full_name"),
|
||||
"address_type": "Billing",
|
||||
"is_primary_address": 1,
|
||||
"email_id": frappe.session.user,
|
||||
}
|
||||
)
|
||||
address_doc.save(ignore_permissions=True)
|
||||
return address_doc.name
|
||||
291
lms/lms/utils.py
291
lms/lms/utils.py
@@ -722,17 +722,6 @@ def get_lesson_count(course):
|
||||
return lesson_count
|
||||
|
||||
|
||||
def get_restriction_details():
|
||||
user = frappe.db.get_value(
|
||||
"User", frappe.session.user, ["profile_complete", "username"], as_dict=True
|
||||
)
|
||||
return {
|
||||
"restrict": not user.profile_complete,
|
||||
"username": user.username,
|
||||
"prefix": frappe.get_hooks("profile_url_prefix")[0] or "/users/",
|
||||
}
|
||||
|
||||
|
||||
def get_all_memberships(member):
|
||||
return frappe.get_all(
|
||||
"LMS Enrollment",
|
||||
@@ -919,39 +908,6 @@ def get_upcoming_evals(student, courses):
|
||||
return upcoming_evals
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_options(doctype, docname, phone, country):
|
||||
if not frappe.db.exists(doctype, docname):
|
||||
frappe.throw(_("Invalid document provided."))
|
||||
|
||||
validate_phone_number(phone, True)
|
||||
details = get_details(doctype, docname)
|
||||
|
||||
details.amount, details.currency = check_multicurrency(
|
||||
details.amount, details.currency, country, details.amount_usd
|
||||
)
|
||||
if details.currency == "INR":
|
||||
details.amount, details.gst_applied = apply_gst(details.amount, country)
|
||||
|
||||
client = get_client()
|
||||
order = create_order(client, details.amount, details.currency)
|
||||
|
||||
options = {
|
||||
"key_id": frappe.db.get_single_value("LMS Settings", "razorpay_key"),
|
||||
"name": frappe.db.get_single_value("Website Settings", "app_name"),
|
||||
"description": _("Payment for {0} course").format(details["title"]),
|
||||
"order_id": order["id"],
|
||||
"amount": cint(order["amount"]) * 100,
|
||||
"currency": order["currency"],
|
||||
"prefill": {
|
||||
"name": frappe.db.get_value("User", frappe.session.user, "full_name"),
|
||||
"email": frappe.session.user,
|
||||
"contact": phone,
|
||||
},
|
||||
}
|
||||
return options
|
||||
|
||||
|
||||
def check_multicurrency(amount, currency, country=None, amount_usd=None):
|
||||
settings = frappe.get_single("LMS Settings")
|
||||
show_usd_equivalent = settings.show_usd_equivalent
|
||||
@@ -1009,145 +965,6 @@ def apply_gst(amount, country=None):
|
||||
return amount, gst_applied
|
||||
|
||||
|
||||
def get_details(doctype, docname):
|
||||
if doctype == "LMS Course":
|
||||
details = frappe.db.get_value(
|
||||
"LMS Course",
|
||||
docname,
|
||||
["name", "title", "paid_course", "currency", "course_price as amount", "amount_usd"],
|
||||
as_dict=True,
|
||||
)
|
||||
if not details.paid_course:
|
||||
frappe.throw(_("This course is free."))
|
||||
else:
|
||||
details = frappe.db.get_value(
|
||||
"LMS Batch",
|
||||
docname,
|
||||
["name", "title", "paid_batch", "currency", "amount", "amount_usd"],
|
||||
as_dict=True,
|
||||
)
|
||||
if not details.paid_batch:
|
||||
frappe.throw(_("To join this batch, please contact the Administrator."))
|
||||
|
||||
return details
|
||||
|
||||
|
||||
def save_address(address):
|
||||
filters = {"email_id": frappe.session.user}
|
||||
exists = frappe.db.exists("Address", filters)
|
||||
if exists:
|
||||
address_doc = frappe.get_last_doc("Address", filters=filters)
|
||||
else:
|
||||
address_doc = frappe.new_doc("Address")
|
||||
|
||||
address_doc.update(address)
|
||||
address_doc.update(
|
||||
{
|
||||
"address_title": frappe.db.get_value("User", frappe.session.user, "full_name"),
|
||||
"address_type": "Billing",
|
||||
"is_primary_address": 1,
|
||||
"email_id": frappe.session.user,
|
||||
}
|
||||
)
|
||||
address_doc.save(ignore_permissions=True)
|
||||
return address_doc.name
|
||||
|
||||
|
||||
def get_client():
|
||||
settings = frappe.get_single("LMS Settings")
|
||||
razorpay_key = settings.razorpay_key
|
||||
razorpay_secret = settings.get_password("razorpay_secret", raise_exception=True)
|
||||
|
||||
if not razorpay_key and not razorpay_secret:
|
||||
frappe.throw(
|
||||
_(
|
||||
"There is a problem with the payment gateway. Please contact the Administrator to proceed."
|
||||
)
|
||||
)
|
||||
|
||||
return razorpay.Client(auth=(razorpay_key, razorpay_secret))
|
||||
|
||||
|
||||
def create_order(client, amount, currency):
|
||||
try:
|
||||
return client.order.create(
|
||||
{
|
||||
"amount": cint(amount) * 100,
|
||||
"currency": currency,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Error during payment: {0} Please contact the Administrator. Amount {1} Currency {2} Formatted {3}"
|
||||
).format(e, amount, currency, cint(amount))
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def verify_payment(response, doctype, docname, address, order_id):
|
||||
client = get_client()
|
||||
client.utility.verify_payment_signature(
|
||||
{
|
||||
"razorpay_order_id": order_id,
|
||||
"razorpay_payment_id": response["razorpay_payment_id"],
|
||||
"razorpay_signature": response["razorpay_signature"],
|
||||
}
|
||||
)
|
||||
|
||||
payment = record_payment(address, response, client, doctype, docname)
|
||||
if doctype == "LMS Course":
|
||||
return create_membership(docname, payment)
|
||||
else:
|
||||
return add_student_to_batch(docname, payment)
|
||||
|
||||
|
||||
def record_payment(address, response, client, doctype, docname):
|
||||
address = frappe._dict(address)
|
||||
address_name = save_address(address)
|
||||
|
||||
payment_details = get_payment_details(doctype, docname, address)
|
||||
payment_doc = frappe.new_doc("LMS Payment")
|
||||
payment_doc.update(
|
||||
{
|
||||
"member": frappe.session.user,
|
||||
"billing_name": address.billing_name,
|
||||
"address": address_name,
|
||||
"payment_received": 1,
|
||||
"order_id": response["razorpay_order_id"],
|
||||
"payment_id": response["razorpay_payment_id"],
|
||||
"amount": payment_details["amount"],
|
||||
"currency": payment_details["currency"],
|
||||
"amount_with_gst": payment_details["amount_with_gst"],
|
||||
"gstin": address.gstin,
|
||||
"pan": address.pan,
|
||||
"source": address.source,
|
||||
"payment_for_document_type": doctype,
|
||||
"payment_for_document": docname,
|
||||
}
|
||||
)
|
||||
payment_doc.save(ignore_permissions=True)
|
||||
return payment_doc
|
||||
|
||||
|
||||
def get_payment_details(doctype, docname, address):
|
||||
amount_field = "course_price" if doctype == "LMS Course" else "amount"
|
||||
amount = frappe.db.get_value(doctype, docname, amount_field)
|
||||
currency = frappe.db.get_value(doctype, docname, "currency")
|
||||
amount_usd = frappe.db.get_value(doctype, docname, "amount_usd")
|
||||
amount_with_gst = 0
|
||||
|
||||
amount, currency = check_multicurrency(amount, currency, None, amount_usd)
|
||||
if currency == "INR" and address.country == "India":
|
||||
amount_with_gst, gst_applied = apply_gst(amount, address.country)
|
||||
|
||||
return {
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"amount_with_gst": amount_with_gst,
|
||||
}
|
||||
|
||||
|
||||
def create_membership(course, payment):
|
||||
membership = frappe.new_doc("LMS Enrollment")
|
||||
membership.update(
|
||||
@@ -1157,24 +974,6 @@ def create_membership(course, payment):
|
||||
return f"/lms/courses/{course}/learn/1-1"
|
||||
|
||||
|
||||
def add_student_to_batch(batchname, payment):
|
||||
student = frappe.new_doc("Batch Student")
|
||||
current_count = frappe.db.count("Batch Student", {"parent": batchname})
|
||||
student.update(
|
||||
{
|
||||
"student": frappe.session.user,
|
||||
"payment": payment.name,
|
||||
"source": payment.source,
|
||||
"parent": batchname,
|
||||
"parenttype": "LMS Batch",
|
||||
"parentfield": "students",
|
||||
"idx": current_count + 1,
|
||||
}
|
||||
)
|
||||
student.save(ignore_permissions=True)
|
||||
return f"/batches/{batchname}"
|
||||
|
||||
|
||||
def get_current_exchange_rate(source, target="USD"):
|
||||
url = f"https://api.frankfurter.app/latest?from={source}&to={target}"
|
||||
|
||||
@@ -1220,6 +1019,7 @@ def get_course_details(course):
|
||||
"featured",
|
||||
"disable_self_learning",
|
||||
"published_on",
|
||||
"category",
|
||||
"status",
|
||||
"paid_course",
|
||||
"course_price",
|
||||
@@ -1775,10 +1575,11 @@ def get_order_summary(doctype, docname, country=None):
|
||||
details.amount, details.currency = check_multicurrency(
|
||||
details.amount, details.currency, country, details.amount_usd
|
||||
)
|
||||
details.original_amount = details.amount
|
||||
details.original_amount_formatted = fmt_money(details.amount, 0, details.currency)
|
||||
|
||||
if details.currency == "INR":
|
||||
details.amount, details.gst_applied = apply_gst(details.amount)
|
||||
details.amount, details.gst_applied = apply_gst(details.amount, country)
|
||||
details.gst_amount_formatted = fmt_money(details.gst_applied, 0, details.currency)
|
||||
|
||||
details.total_amount_formatted = fmt_money(details.amount, 0, details.currency)
|
||||
@@ -1836,3 +1637,89 @@ def publish_notifications(doc, method):
|
||||
frappe.publish_realtime(
|
||||
"publish_lms_notifications", user=doc.for_user, after_commit=True
|
||||
)
|
||||
|
||||
|
||||
def update_payment_record(doctype, docname):
|
||||
request = frappe.get_all(
|
||||
"Integration Request",
|
||||
{
|
||||
"reference_doctype": doctype,
|
||||
"reference_docname": docname,
|
||||
"owner": frappe.session.user,
|
||||
},
|
||||
order_by="creation desc",
|
||||
limit=1,
|
||||
)
|
||||
|
||||
if len(request):
|
||||
data = frappe.db.get_value("Integration Request", request[0].name, "data")
|
||||
data = frappe._dict(json.loads(data))
|
||||
|
||||
payment_gateway = data.get("payment_gateway")
|
||||
if payment_gateway == "Razorpay":
|
||||
payment_id = "razorpay_payment_id"
|
||||
elif "Stripe" in payment_gateway:
|
||||
payment_id = "stripe_token_id"
|
||||
else:
|
||||
payment_id = "order_id"
|
||||
|
||||
frappe.db.set_value(
|
||||
"LMS Payment",
|
||||
data.payment,
|
||||
{
|
||||
"payment_received": 1,
|
||||
"payment_id": data.get(payment_id),
|
||||
"order_id": data.get("order_id"),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
if doctype == "LMS Course":
|
||||
enroll_in_course(data.payment, docname)
|
||||
else:
|
||||
enroll_in_batch(data.payment, docname)
|
||||
except Exception as e:
|
||||
frappe.log_error(frappe.get_traceback(), _("Enrollment Failed"))
|
||||
|
||||
|
||||
def enroll_in_course(payment_name, course):
|
||||
if not frappe.db.exists(
|
||||
"LMS Enrollment", {"member": frappe.session.user, "course": course}
|
||||
):
|
||||
enrollment = frappe.new_doc("LMS Enrollment")
|
||||
payment = frappe.db.get_value(
|
||||
"LMS Payment", payment_name, ["name", "source"], as_dict=True
|
||||
)
|
||||
|
||||
enrollment.update(
|
||||
{
|
||||
"member": frappe.session.user,
|
||||
"course": course,
|
||||
"payment": payment.name,
|
||||
}
|
||||
)
|
||||
enrollment.save(ignore_permissions=True)
|
||||
|
||||
|
||||
def enroll_in_batch(payment_name, batch):
|
||||
if not frappe.db.exists(
|
||||
"Batch Student", {"parent": batch, "student": frappe.session.user}
|
||||
):
|
||||
student = frappe.new_doc("Batch Student")
|
||||
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": frappe.session.user,
|
||||
"payment": payment.name,
|
||||
"source": payment.source,
|
||||
"parent": batch,
|
||||
"parenttype": "LMS Batch",
|
||||
"parentfield": "students",
|
||||
"idx": current_count + 1,
|
||||
}
|
||||
)
|
||||
student.save(ignore_permissions=True)
|
||||
|
||||
3320
lms/locale/main.pot
3320
lms/locale/main.pot
File diff suppressed because it is too large
Load Diff
@@ -90,4 +90,4 @@ lms.patches.v1_0.set_published_on
|
||||
lms.patches.v2_0.fix_progress_percentage
|
||||
lms.patches.v2_0.add_discussion_topic_titles
|
||||
lms.patches.v2_0.sidebar_settings
|
||||
lms.patches.v2_0.delete_certificate_request_notification
|
||||
lms.patches.v2_0.delete_certificate_request_notification #18-09-2024
|
||||
@@ -2,4 +2,10 @@ import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
frappe.db.delete("Notification", "Certificate Request Creation")
|
||||
delete_notification("Certificate Request Creation")
|
||||
delete_notification("Certificate Request Reminder")
|
||||
|
||||
|
||||
def delete_notification(notification_name):
|
||||
if frappe.db.exists("Notification", notification_name):
|
||||
frappe.db.delete("Notification", notification_name)
|
||||
|
||||
@@ -227,8 +227,7 @@ def assignment_renderer(detail):
|
||||
|
||||
|
||||
def show_custom_signup():
|
||||
if frappe.db.get_single_value(
|
||||
"LMS Settings", "terms_of_use"
|
||||
) or frappe.db.get_single_value("LMS Settings", "privacy_policy"):
|
||||
settings = frappe.get_single("LMS Settings")
|
||||
if settings.custom_signup_content or settings.user_category:
|
||||
return "lms/templates/signup-form.html"
|
||||
return "frappe/templates/signup.html"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{% set custom_signup_content = frappe.db.get_single_value("LMS Settings", "custom_signup_content") %}
|
||||
<form class="signup-form" role="form">
|
||||
<div class="page-card-body">
|
||||
<div class="form-group">
|
||||
@@ -31,6 +32,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if custom_signup_content %}
|
||||
<div class="form-group">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
@@ -39,11 +41,12 @@
|
||||
data-fieldtype="Check" data-fieldname="terms" id="signup-terms" required>
|
||||
</span>
|
||||
<span class="label-area">
|
||||
{{ _("I have read and agree to your {0}").format(get_signup_optin_checks()) }}
|
||||
{{ custom_signup_content }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="page-card-actions">
|
||||
<button class="btn btn-sm btn-primary btn-block btn-signup"
|
||||
|
||||
Reference in New Issue
Block a user