Compare commits

...

37 Commits

Author SHA1 Message Date
Frappe PR Bot
736d79b8c9 chore(release): Bumped to Version 2.8.0 2024-10-09 06:04:56 +00:00
Jannat Patel
98c0bd5f3e Merge pull request #1042 from frappe/pot_develop_2024-10-04
chore: update POT file
2024-10-09 11:34:01 +05:30
Jannat Patel
289a0f9122 Merge pull request #1046 from pateljannat/issues-38
fix: quiz columns
2024-10-08 16:27:04 +05:30
Jannat Patel
3cd08c80c8 fix: reduced with of marks column 2024-10-08 16:09:21 +05:30
Jannat Patel
3d82c36250 fix: quiz columns 2024-10-08 16:03:37 +05:30
Jannat Patel
9b9af0215a Merge pull request #1045 from pateljannat/issues-37
fix: using google docs viewer to render pdf
2024-10-08 12:37:19 +05:30
Jannat Patel
2e4cf02737 fix: using google docs viewer to render pdf 2024-10-08 12:00:51 +05:30
Jannat Patel
438e9e1c47 Merge pull request #1044 from pateljannat/open-ended-questions
feat: open ended questions
2024-10-08 10:37:44 +05:30
Jannat Patel
36ded70eef fix: only allow instructor and moderator on submission page 2024-10-08 10:21:45 +05:30
Jannat Patel
ba78a15a1f fix: ui test button label 2024-10-08 10:14:04 +05:30
Jannat Patel
93061194bb fix: error toast when saving marks 2024-10-08 10:13:07 +05:30
Jannat Patel
6d41e4e552 feat: open ended questions 2024-10-07 21:18:42 +05:30
frappe-pr-bot
3b06968d0a chore: update POT file 2024-10-04 16:04:32 +00:00
Frappe PR Bot
fc81f1aa26 chore(release): Bumped to Version 2.7.0 2024-10-02 06:53:07 +00:00
Jannat Patel
59d8848125 Merge pull request #1035 from pateljannat/payments
feat: payments app integration
2024-10-02 12:22:00 +05:30
Jannat Patel
a067695f71 fix: removed help article from course lesson 2024-10-01 15:43:45 +05:30
Jannat Patel
be870e8145 fix: payment gateway fields 2024-10-01 15:17:17 +05:30
Jannat Patel
8a17dca351 fix: minor ui changes 2024-10-01 10:43:37 +05:30
Jannat Patel
1c9f636ad1 Merge pull request #1032 from frappe/pot_develop_2024-09-27
chore: update POT file
2024-10-01 09:42:57 +05:30
Jannat Patel
008cc66cdd chore: refactor payment settings 2024-09-30 18:30:53 +05:30
Jannat Patel
b6bf9c0032 Merge branch 'develop' of https://github.com/frappe/lms into payments 2024-09-30 10:16:52 +05:30
Jannat Patel
d295898674 Merge pull request #1033 from pateljannat/issues-35
fix: misc UI fixes
2024-09-27 22:15:43 +05:30
frappe-pr-bot
4fdca4691a chore: update POT file 2024-09-27 16:04:07 +00:00
Jannat Patel
7c055af496 fix: telemetry capture issue 2024-09-27 21:32:46 +05:30
Jannat Patel
60a3da283e refactor: billing page ui 2024-09-27 14:14:03 +05:30
Jannat Patel
576258ec6e fix: pass options to setting fields 2024-09-27 07:05:16 +05:30
Jannat Patel
01120fbc48 chore: resolved conflicts 2024-09-27 06:24:12 +05:30
Jannat Patel
ad07f883b5 fix: misc UI fixes 2024-09-27 06:19:38 +05:30
Jannat Patel
bb9b179e05 Merge pull request #1031 from pateljannat/brand-settings
feat:  brand settings
2024-09-26 14:01:19 +05:30
Jannat Patel
11a9bff57d fix: dirty form for branding section 2024-09-26 12:58:00 +05:30
Jannat Patel
e18f0c9dad feat: brand settings 2024-09-26 12:09:58 +05:30
Jannat Patel
41ad3d00de Merge pull request #1030 from pateljannat/fix-evaluation-issue
fix: evaluation error message issue
2024-09-25 11:29:42 +05:30
Jannat Patel
33c76e842f fix: evaluation error message issue 2024-09-25 11:10:26 +05:30
Jannat Patel
35a7cce283 feat: payment gateway settings 2024-09-25 10:50:53 +05:30
Jannat Patel
e0f569c382 feat: payment flow with payments app 2024-09-24 18:14:34 +05:30
Jannat Patel
d8ab88be28 Merge branch 'develop' of https://github.com/frappe/lms into payments 2024-09-24 14:14:49 +05:30
Jannat Patel
cef4b70182 feat: payment through payments app 2024-09-19 12:46:56 +05:30
54 changed files with 3626 additions and 2156 deletions

View File

@@ -5,7 +5,7 @@ describe("Course Creation", () => {
cy.visit("/lms/courses"); cy.visit("/lms/courses");
// Create a course // Create a course
cy.get("a").contains("New Course").click(); cy.get("a").contains("New").click();
cy.wait(1000); cy.wait(1000);
cy.url().should("include", "/courses/new/edit"); cy.url().should("include", "/courses/new/edit");

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,33 +1,30 @@
<template> <template>
<Button <div class="flex items-center justify-between mb-5">
v-if="user.data.is_moderator" <div class="text-lg font-semibold">
variant="solid" {{ __('Live Class') }}
class="float-right mb-5" </div>
@click="openLiveClassModal" <Button v-if="user.data.is_moderator" @click="openLiveClassModal">
>
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
<span> <span>
{{ __('Add Live Class') }} {{ __('Add') }}
</span> </span>
</Button> </Button>
<div class="text-lg font-semibold mb-5">
{{ __('Live Class') }}
</div> </div>
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5"> <div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
<div <div
v-for="cls in liveClasses.data" v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full p-3" class="flex flex-col border rounded-md h-full text-sm text-gray-700 p-3"
> >
<div class="font-semibold text-lg mb-4"> <div class="font-semibold text-gray-900 text-lg mb-4">
{{ cls.title }} {{ cls.title }}
</div> </div>
<div class="mb-4"> <div class="leading-5 text-gray-700 text-sm mb-4">
{{ cls.description }} {{ cls.description }}
</div> </div>
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<Calendar class="w-4 h-4 stroke-1.5" /> <Calendar class="w-4 h-4 stroke-1.5 text-gray-700" />
<span class="ml-2"> <span class="ml-2">
{{ dayjs(cls.date).format('DD MMMM YYYY') }} {{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span> </span>
@@ -38,7 +35,7 @@
{{ formatTime(cls.time) }} {{ formatTime(cls.time) }}
</span> </span>
</div> </div>
<div class="flex items-center space-x-2 mt-auto"> <div class="flex items-center space-x-2 text-gray-900 mt-auto">
<a <a
:href="cls.start_url" :href="cls.start_url"
target="_blank" target="_blank"
@@ -90,7 +87,6 @@ const liveClasses = createListResource({
doctype: 'LMS Live Class', doctype: 'LMS Live Class',
filters: { filters: {
batch_name: props.batch, batch_name: props.batch,
date: ['>=', new Date()],
}, },
fields: [ fields: [
'title', 'title',

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,20 @@
:label="activeTab.label" :label="activeTab.label"
:description="activeTab.description" :description="activeTab.description"
/> />
<PaymentSettings
v-else-if="activeTab.label === 'Payment Gateway'"
:label="activeTab.label"
:description="activeTab.description"
:data="data"
:fields="activeTab.fields"
/>
<BrandSettings
v-else-if="activeTab.label === 'Branding'"
:label="activeTab.label"
:description="activeTab.description"
:fields="activeTab.fields"
:data="branding"
/>
<SettingDetails <SettingDetails
v-else v-else
:fields="activeTab.fields" :fields="activeTab.fields"
@@ -58,13 +72,15 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, createDocumentResource } from 'frappe-ui' import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
import SettingDetails from '../SettingDetails.vue' import SettingDetails from '../SettingDetails.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
import Members from '@/components/Members.vue' import Members from '@/components/Members.vue'
import Categories from '@/components/Categories.vue' import Categories from '@/components/Categories.vue'
import BrandSettings from '@/components/BrandSettings.vue'
import PaymentSettings from '@/components/PaymentSettings.vue'
const show = defineModel() const show = defineModel()
const doctype = ref('LMS Settings') const doctype = ref('LMS Settings')
@@ -79,6 +95,12 @@ const data = createDocumentResource({
auto: true, auto: true,
}) })
const branding = createResource({
url: 'lms.lms.api.get_branding',
auto: true,
cache: 'brand',
})
const tabsStructure = computed(() => { const tabsStructure = computed(() => {
return [ return [
{ {
@@ -92,17 +114,6 @@ const tabsStructure = computed(() => {
}, },
], ],
}, },
{
label: 'Settings',
hideLabel: true,
items: [
{
label: 'Categories',
description: 'Manage the members of your learning system',
icon: 'Network',
},
],
},
{ {
label: 'Settings', label: 'Settings',
hideLabel: true, hideLabel: true,
@@ -114,14 +125,10 @@ const tabsStructure = computed(() => {
'Configure the payment gateway and other payment related settings', 'Configure the payment gateway and other payment related settings',
fields: [ fields: [
{ {
label: 'Razorpay Key', label: 'Payment Gateway',
name: 'razorpay_key', name: 'payment_gateway',
type: 'text', type: 'Link',
}, doctype: 'Payment Gateway',
{
label: 'Razorpay Secret',
name: 'razorpay_secret',
type: 'password',
}, },
{ {
label: 'Default Currency', label: 'Default Currency',
@@ -129,9 +136,6 @@ const tabsStructure = computed(() => {
type: 'Link', type: 'Link',
doctype: 'Currency', doctype: 'Currency',
}, },
{
type: 'Column Break',
},
{ {
label: 'Apply GST for India', label: 'Apply GST for India',
name: 'apply_gst', name: 'apply_gst',
@@ -151,10 +155,67 @@ const tabsStructure = computed(() => {
}, },
], ],
}, },
{
label: 'Settings',
hideLabel: true,
items: [
{
label: 'Categories',
description: 'Manage the members of your learning system',
icon: 'Network',
},
],
},
{ {
label: 'Customise', label: 'Customise',
hideLabel: false, hideLabel: false,
items: [ items: [
{
label: 'Branding',
icon: 'Blocks',
fields: [
{
label: 'Brand Name',
name: 'app_name',
type: 'text',
},
{
label: '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', label: 'Sidebar',
icon: 'PanelLeftIcon', icon: 'PanelLeftIcon',
@@ -198,7 +259,6 @@ const tabsStructure = computed(() => {
{ {
label: 'Email Templates', label: 'Email Templates',
icon: 'MailPlus', icon: 'MailPlus',
description: 'Create email templates with the content you want',
fields: [ fields: [
{ {
label: 'Batch Confirmation Template', label: 'Batch Confirmation Template',

View File

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

View File

@@ -67,15 +67,8 @@
<span class="mr-2"> <span class="mr-2">
{{ __('Question {0}').format(activeQuestion) }}: {{ __('Question {0}').format(activeQuestion) }}:
</span> </span>
<span v-if="questionDetails.data.type == 'User Input'"> <span>
{{ __('Type your answer') }} {{ getInstructions(questionDetails.data) }}
</span>
<span v-else>
{{
questionDetails.data.multiple
? __('Choose all answers that apply')
: __('Choose one answer')
}}
</span> </span>
</div> </div>
<div class="text-gray-900 text-sm font-semibold item-left"> <div class="text-gray-900 text-sm font-semibold item-left">
@@ -139,7 +132,7 @@
{{ questionDetails.data[`explanation_${index}`] }} {{ questionDetails.data[`explanation_${index}`] }}
</div> </div>
</div> </div>
<div v-else> <div v-else-if="questionDetails.data.type == 'User Input'">
<FormControl <FormControl
v-model="possibleAnswer" v-model="possibleAnswer"
type="textarea" type="textarea"
@@ -159,6 +152,16 @@
</Badge> </Badge>
</div> </div>
</div> </div>
<div v-else>
<TextEditor
class="mt-4"
:content="possibleAnswer"
@change="(val) => (possibleAnswer = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
<div class="flex items-center justify-between mt-5"> <div class="flex items-center justify-between mt-5">
<div> <div>
{{ {{
@@ -169,7 +172,11 @@
}} }}
</div> </div>
<Button <Button
v-if="quiz.data.show_answers && !showAnswers.length" v-if="
quiz.data.show_answers &&
!showAnswers.length &&
questionDetails.data.type != 'Open Ended'
"
@click="checkAnswer()" @click="checkAnswer()"
> >
<span> <span>
@@ -193,11 +200,18 @@
</div> </div>
</div> </div>
</div> </div>
<div v-else class="border rounded-md p-20 text-center"> <div v-else class="border rounded-md p-20 text-center space-y-4">
<div class="text-lg font-semibold"> <div class="text-lg font-semibold">
{{ __('Quiz Summary') }} {{ __('Quiz Summary') }}
</div> </div>
<div> <div v-if="quizSubmission.data.is_open_ended">
{{
__(
"Your submission has been successfully saved. The instructor will review and grade it shortly, and you'll be notified of your final result."
)
}}
</div>
<div v-else>
{{ {{
__( __(
'You got {0}% correct answers with a score of {1} out of {2}' 'You got {0}% correct answers with a score of {1} out of {2}'
@@ -236,7 +250,7 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { Badge, Button, createResource, ListView } from 'frappe-ui' import { Badge, Button, createResource, ListView, TextEditor } from 'frappe-ui'
import { ref, watch, reactive, inject } from 'vue' import { ref, watch, reactive, inject } from 'vue'
import { createToast } from '@/utils/' import { createToast } from '@/utils/'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next' import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
@@ -450,9 +464,10 @@ const addToLocalStorage = () => {
} }
const nextQuetion = () => { const nextQuetion = () => {
if (!quiz.data.show_answers) { if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
checkAnswer() checkAnswer()
} else { } else {
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
resetQuestion() resetQuestion()
} }
} }
@@ -467,7 +482,8 @@ const resetQuestion = () => {
const submitQuiz = () => { const submitQuiz = () => {
if (!quiz.data.show_answers) { if (!quiz.data.show_answers) {
checkAnswer() if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
else checkAnswer()
setTimeout(() => { setTimeout(() => {
createSubmission() createSubmission()
}, 500) }, 500)
@@ -490,6 +506,13 @@ const resetQuiz = () => {
populateQuestions() populateQuestions()
} }
const getInstructions = (question) => {
if (question.type == 'Choices')
if (question.multiple) return __('Choose all answers that apply')
else return __('Choose one answer')
else return __('Type your answer')
}
const getSubmissionColumns = () => { const getSubmissionColumns = () => {
return [ return [
{ {

View File

@@ -1,53 +1,23 @@
<template> <template>
<div class="flex flex-col justify-between h-full"> <div class="flex flex-col justify-between h-full">
<div> <div>
<div class="flex itemsc-center justify-between">
<div class="font-semibold mb-1"> <div class="font-semibold mb-1">
{{ __(label) }} {{ __(label) }}
</div> </div>
<Badge
v-if="data.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</div>
<div class="text-xs text-gray-600"> <div class="text-xs text-gray-600">
{{ __(description) }} {{ __(description) }}
</div> </div>
</div> </div>
<div
class="my-5"
:class="{ 'flex justify-between w-full': columns.length > 1 }"
>
<div v-for="(column, index) in columns" :key="index">
<div
class="flex flex-col space-y-5"
:class="columns.length > 1 ? 'w-72' : 'w-full'"
>
<div v-for="field in column">
<Link
v-if="field.type == 'Link'"
v-model="field.value"
:doctype="field.doctype"
:label="field.label"
/>
<Codemirror <SettingFields :fields="fields" :data="data.doc" />
v-else-if="field.type == 'Code'"
v-model:value="field.value"
:label="field.label"
:height="200"
:options="{
mode: field.mode,
theme: 'seti',
}"
/>
<FormControl
v-else
:key="field.name"
v-model="field.value"
:label="field.label"
:type="field.type"
:rows="field.rows"
/>
</div>
</div>
</div>
</div>
<div class="flex flex-row-reverse mt-auto"> <div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="data.save.loading" @click="update"> <Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }} {{ __('Update') }}
@@ -57,12 +27,8 @@
</template> </template>
<script setup> <script setup>
import { FormControl, Button } from 'frappe-ui' import { Button, Badge } from 'frappe-ui'
import { computed } from 'vue' import SettingFields from '@/components/SettingFields.vue'
import Link from '@/components/Controls/Link.vue'
import Codemirror from 'codemirror-editor-vue3'
import 'codemirror/theme/seti.css'
import 'codemirror/mode/htmlmixed/htmlmixed.js'
const props = defineProps({ const props = defineProps({
fields: { fields: {
@@ -82,40 +48,16 @@ const props = defineProps({
}, },
}) })
const columns = computed(() => {
const cols = []
let currentColumn = []
props.fields.forEach((field) => {
if (field.type === 'Column Break') {
if (currentColumn.length > 0) {
cols.push(currentColumn)
currentColumn = []
}
} else {
if (field.type == 'checkbox') {
field.value = props.data.doc[field.name] ? true : false
} else {
field.value = props.data.doc[field.name]
}
currentColumn.push(field)
}
})
if (currentColumn.length > 0) {
cols.push(currentColumn)
}
return cols
})
const update = () => { const update = () => {
props.fields.forEach((f) => { props.fields.forEach((f) => {
if (f.type != 'Column Break') {
props.data.doc[f.name] = f.value props.data.doc[f.name] = f.value
}
}) })
props.data.save.submit() props.data.save.submit()
} }
</script> </script>
<style> <style>
.CodeMirror pre.CodeMirror-line, .CodeMirror pre.CodeMirror-line,
.CodeMirror pre.CodeMirror-line-like { .CodeMirror pre.CodeMirror-line-like {

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

View File

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

View File

@@ -11,11 +11,11 @@
: 'hover:bg-gray-200 px-2 w-52' : 'hover:bg-gray-200 px-2 w-52'
" "
> >
<span <img
v-if="branding.data?.brand_html" v-if="branding.data?.banner_image"
v-html="branding.data?.brand_html" :src="branding.data?.banner_image.file_url"
class="w-8 h-8 rounded flex-shrink-0" class="w-8 h-8 rounded flex-shrink-0"
></span> />
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" /> <LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
<div <div
class="flex flex-1 flex-col text-left duration-300 ease-in-out" class="flex flex-1 flex-col text-left duration-300 ease-in-out"
@@ -28,11 +28,10 @@
<div class="text-base font-medium text-gray-900 leading-none"> <div class="text-base font-medium text-gray-900 leading-none">
<span <span
v-if=" v-if="
branding.data?.brand_name && branding.data?.app_name && branding.data?.app_name != 'Frappe'
branding.data?.brand_name != 'Frappe'
" "
> >
{{ branding.data?.brand_name }} {{ branding.data?.app_name }}
</span> </span>
<span v-else> Learning </span> <span v-else> Learning </span>
</div> </div>

View File

@@ -27,7 +27,7 @@
<template #prefix> <template #prefix>
<Plus class="h-4 w-4 stroke-1.5" /> <Plus class="h-4 w-4 stroke-1.5" />
</template> </template>
{{ __('New Batch') }} {{ __('New') }}
</Button> </Button>
</router-link> </router-link>
</div> </div>

View File

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

View File

@@ -8,7 +8,7 @@
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]" :items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
/> />
<div class="flex space-x-2 justify-end"> <div class="flex space-x-2 justify-end">
<div class="w-44"> <div class="w-46 md:w-44">
<FormControl <FormControl
v-if="categories.data?.length" v-if="categories.data?.length"
type="select" type="select"
@@ -17,7 +17,7 @@
:placeholder="__('Category')" :placeholder="__('Category')"
/> />
</div> </div>
<div class="w-36"> <div class="w-28 md:w-36">
<FormControl <FormControl
type="text" type="text"
placeholder="Search" placeholder="Search"
@@ -41,7 +41,7 @@
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
{{ __('New Course') }} {{ __('New') }}
</Button> </Button>
</router-link> </router-link>
</div> </div>

View File

@@ -244,7 +244,10 @@ const lesson = createResource({
onSuccess(data) { onSuccess(data) {
lessonProgress.value = data.membership?.progress lessonProgress.value = data.membership?.progress
if (data.content) editor.value = renderEditor('editor', data.content) if (data.content) editor.value = renderEditor('editor', data.content)
if (JSON.parse(data.instructor_content)?.blocks?.length > 1) if (
data.instructor_content &&
JSON.parse(data.instructor_content)?.blocks?.length > 1
)
instructorEditor.value = renderEditor( instructorEditor.value = renderEditor(
'instructor-content', 'instructor-content',
data.instructor_content data.instructor_content

View File

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

View File

@@ -3,9 +3,24 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5" class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<div class="space-x-2">
<router-link
v-if="quizDetails.data?.name"
:to="{
name: 'QuizSubmissionList',
params: {
quizID: quizDetails.data.name,
},
}"
>
<Button>
{{ __('Submission List') }}
</Button>
</router-link>
<Button variant="solid" @click="submitQuiz()"> <Button variant="solid" @click="submitQuiz()">
{{ __('Save') }} {{ __('Save') }}
</Button> </Button>
</div>
</header> </header>
<div class="w-3/4 mx-auto py-5"> <div class="w-3/4 mx-auto py-5">
<!-- Details --> <!-- Details -->
@@ -347,17 +362,17 @@ const questionColumns = computed(() => {
{ {
label: __('ID'), label: __('ID'),
key: 'question', key: 'question',
width: '25%', width: '10rem',
}, },
{ {
label: __('Question'), label: __('Question'),
key: __('question_detail'), key: __('question_detail'),
width: '60%', width: '40rem',
}, },
{ {
label: __('Marks'), label: __('Marks'),
key: 'marks', key: 'marks',
width: '10%', width: '5rem',
}, },
] ]
}) })

View File

@@ -0,0 +1,58 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
</header>
<div class="md:w-7/12 md:mx-auto mx-4 py-10">
<Quiz :quizName="quizID" />
</div>
</template>
<script setup>
import Quiz from '@/components/Quiz.vue'
import { createResource, Breadcrumbs } from 'frappe-ui'
import { computed, inject, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user')
const router = useRouter()
onMounted(() => {
if (!user.data) {
router.push({ name: 'Courses' })
}
})
const props = defineProps({
quizID: {
type: String,
required: true,
},
})
const title = createResource({
url: 'frappe.client.get_value',
params: {
doctype: 'LMS Quiz',
fieldname: 'title',
filters: {
name: props.quizID,
},
},
auto: true,
})
const breadcrumbs = computed(() => {
return [{ label: __('Quiz Submission') }, { label: title.data?.title }]
})
const pageMeta = computed(() => {
return {
title: title.data?.title,
description: __('Quiz Submission'),
}
})
updateDocumentTitle(pageMeta)
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -499,3 +499,10 @@ export function singularize(word) {
(r) => endings[r] (r) => endings[r]
) )
} }
export const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
return __('Only image file is allowed.')
}
}

View File

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

View File

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

View File

View File

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

View File

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

View File

@@ -289,11 +289,13 @@ def get_file_info(file_url):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_branding(): def get_branding():
"""Get branding details.""" """Get branding details."""
return { website_settings = frappe.get_single("Website Settings")
"brand_name": frappe.db.get_single_value("Website Settings", "app_name"), image_fields = ["banner_image", "footer_logo", "favicon"]
"brand_html": frappe.db.get_single_value("Website Settings", "brand_html"),
"favicon": frappe.db.get_single_value("Website Settings", "favicon"), for field in image_fields:
} website_settings.update({field: get_file_info(website_settings.get(field))})
return website_settings
@frappe.whitelist() @frappe.whitelist()
@@ -706,3 +708,49 @@ def delete_documents(doctype, documents):
frappe.only_for("Moderator") frappe.only_for("Moderator")
for doc in documents: for doc in documents:
frappe.delete_doc(doctype, doc) frappe.delete_doc(doctype, doc)
@frappe.whitelist()
def get_payment_gateway_details(payment_gateway):
fields = []
gateway = frappe.get_doc("Payment Gateway", payment_gateway)
if gateway.gateway_controller is None:
try:
data = frappe.get_doc(f"{payment_gateway} Settings").as_dict()
meta = frappe.get_meta(f"{payment_gateway} Settings").fields
doctype = f"{payment_gateway} Settings"
docname = f"{payment_gateway} Settings"
except Exception:
frappe.throw(_("{0} Settings not found").format(payment_gateway))
else:
try:
data = frappe.get_doc(gateway.gateway_settings, gateway.gateway_controller).as_dict()
meta = frappe.get_meta(gateway.gateway_settings).fields
doctype = gateway.gateway_settings
docname = gateway.gateway_controller
except Exception:
frappe.throw(_("{0} Settings not found").format(payment_gateway))
for row in meta:
if row.fieldtype not in ["Column Break", "Section Break"]:
if row.fieldtype in ["Attach", "Attach Image"]:
fieldtype = "Upload"
data[row.fieldname] = get_file_info(data.get(row.fieldname))
else:
fieldtype = row.fieldtype
fields.append(
{
"label": row.label,
"name": row.fieldname,
"type": fieldtype,
}
)
return {
"fields": fields,
"data": data,
"doctype": doctype,
"docname": docname,
}

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ from lms.lms.utils import (
get_lesson_url, get_lesson_url,
get_quiz_details, get_quiz_details,
get_assignment_details, get_assignment_details,
update_payment_record,
) )
from frappe.email.doctype.email_template.email_template import get_email_template from frappe.email.doctype.email_template.email_template import get_email_template
@@ -26,6 +27,7 @@ class LMSBatch(Document):
self.validate_batch_end_date() self.validate_batch_end_date()
self.validate_duplicate_courses() self.validate_duplicate_courses()
self.validate_duplicate_students() self.validate_duplicate_students()
self.validate_payments_app()
self.validate_duplicate_assessments() self.validate_duplicate_assessments()
self.validate_membership() self.validate_membership()
self.validate_timetable() self.validate_timetable()
@@ -55,6 +57,12 @@ class LMSBatch(Document):
_("Course {0} has already been added to this batch.").format(frappe.bold(title)) _("Course {0} has already been added to this batch.").format(frappe.bold(title))
) )
def validate_payments_app(self):
if self.paid_batch:
installed_apps = frappe.get_installed_apps()
if "payments" not in installed_apps:
frappe.throw(_("Please install the Payments app to create a paid batches."))
def validate_duplicate_assessments(self): def validate_duplicate_assessments(self):
assessments = [row.assessment_name for row in self.assessment] assessments = [row.assessment_name for row in self.assessment]
for assessment in self.assessment: for assessment in self.assessment:
@@ -164,23 +172,9 @@ class LMSBatch(Document):
_("Row #{0} Date cannot be outside the batch duration.").format(schedule.idx) _("Row #{0} Date cannot be outside the batch duration.").format(schedule.idx)
) )
def on_payment_authorized(self, payment_status):
@frappe.whitelist() if payment_status in ["Authorized", "Completed"]:
def remove_student(student, batch_name): update_payment_record("LMS Batch", self.name)
frappe.only_for("Moderator")
frappe.db.delete("Batch Student", {"student": student, "parent": batch_name})
@frappe.whitelist()
def remove_course(course, parent):
frappe.only_for("Moderator")
frappe.db.delete("Batch Course", {"course": course, "parent": parent})
@frappe.whitelist()
def remove_assessment(assessment, parent):
frappe.only_for("Moderator")
frappe.db.delete("LMS Assessment", {"assessment_name": assessment, "parent": parent})
@frappe.whitelist() @frappe.whitelist()

View File

@@ -8,7 +8,7 @@ from frappe.model.document import Document
from frappe.utils import cint, today from frappe.utils import cint, today
from frappe.utils.telemetry import capture from frappe.utils.telemetry import capture
from lms.lms.utils import get_chapters, can_create_courses from lms.lms.utils import get_chapters, can_create_courses
from ...utils import generate_slug, validate_image from ...utils import generate_slug, validate_image, update_payment_record
from frappe import _ from frappe import _
@@ -18,6 +18,7 @@ class LMSCourse(Document):
self.validate_instructors() self.validate_instructors()
self.validate_video_link() self.validate_video_link()
self.validate_status() self.validate_status()
self.validate_payments_app()
self.image = validate_image(self.image) self.image = validate_image(self.image)
def validate_published(self): def validate_published(self):
@@ -44,10 +45,20 @@ class LMSCourse(Document):
if self.published: if self.published:
self.status = "Approved" self.status = "Approved"
def validate_payments_app(self):
if self.paid_course:
installed_apps = frappe.get_installed_apps()
if "payments" not in installed_apps:
frappe.throw(_("Please install the Payments app to create a paid courses."))
def on_update(self): def on_update(self):
if not self.upcoming and self.has_value_changed("upcoming"): if not self.upcoming and self.has_value_changed("upcoming"):
self.send_email_to_interested_users() self.send_email_to_interested_users()
def on_payment_authorized(self, payment_status):
if payment_status in ["Authorized", "Completed"]:
update_payment_record("LMS Course", self.name)
def send_email_to_interested_users(self): def send_email_to_interested_users(self):
interested_users = frappe.get_all( interested_users = frappe.get_all(
"LMS Course Interest", {"course": self.name}, ["name", "user"] "LMS Course Interest", {"course": self.name}, ["name", "user"]

View File

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

View File

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

View File

@@ -3,7 +3,8 @@
import json import json
import frappe import frappe
from frappe import _ import re
from frappe import _, safe_decode
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cstr, comma_and, cint from frappe.utils import cstr, comma_and, cint
from fuzzywuzzy import fuzz from fuzzywuzzy import fuzz
@@ -13,6 +14,9 @@ from lms.lms.utils import (
has_course_moderator_role, has_course_moderator_role,
has_course_instructor_role, has_course_instructor_role,
) )
from binascii import Error as BinasciiError
from frappe.utils.file_manager import safe_b64decode
from frappe.core.doctype.file.utils import get_random_filename
class LMSQuiz(Document): class LMSQuiz(Document):
@@ -20,6 +24,7 @@ class LMSQuiz(Document):
self.validate_duplicate_questions() self.validate_duplicate_questions()
self.validate_limit() self.validate_limit()
self.calculate_total_marks() self.calculate_total_marks()
self.validate_open_ended_questions()
def validate_duplicate_questions(self): def validate_duplicate_questions(self):
questions = [row.question for row in self.questions] questions = [row.question for row in self.questions]
@@ -48,6 +53,19 @@ class LMSQuiz(Document):
else: else:
self.total_marks = sum(cint(question.marks) for question in self.questions) self.total_marks = sum(cint(question.marks) for question in self.questions)
def validate_open_ended_questions(self):
types = [question.type for question in self.questions]
types = set(types)
if "Open Ended" in types and len(types) > 1:
frappe.throw(
_(
"If you want open ended questions then make sure each question in the quiz is of open ended type."
)
)
else:
self.show_answers = 0
def autoname(self): def autoname(self):
if not self.name: if not self.name:
self.name = generate_slug(self.title, "LMS Quiz") self.name = generate_slug(self.title, "LMS Quiz")
@@ -81,34 +99,50 @@ def set_total_marks(questions):
def quiz_summary(quiz, results): def quiz_summary(quiz, results):
score = 0 score = 0
results = results and json.loads(results) results = results and json.loads(results)
is_open_ended = False
for result in results: for result in results:
correct = result["is_correct"][0]
for point in result["is_correct"]:
correct = correct and point
result["is_correct"] = correct
question_details = frappe.db.get_value( question_details = frappe.db.get_value(
"LMS Quiz Question", "LMS Quiz Question",
{"parent": quiz, "question": result["question_name"]}, {"parent": quiz, "question": result["question_name"]},
["question", "marks", "question_detail"], ["question", "marks", "question_detail", "type"],
as_dict=1, as_dict=1,
) )
result["question_name"] = question_details.question result["question_name"] = question_details.question
result["question"] = question_details.question_detail result["question"] = question_details.question_detail
marks = question_details.marks if correct else 0 result["marks_out_of"] = question_details.marks
quiz_details = frappe.get_doc(
"LMS Quiz",
quiz,
["total_marks", "passing_percentage", "lesson", "course"],
as_dict=1,
)
score = 0
percentage = 0
score_out_of = quiz_details.total_marks
if question_details.type != "Open Ended":
correct = result["is_correct"][0]
for point in result["is_correct"]:
correct = correct and point
result["is_correct"] = correct
marks = question_details.marks if correct else 0
result["marks"] = marks result["marks"] = marks
score += marks score += marks
del result["question_name"] del result["question_name"]
quiz_details = frappe.db.get_value(
"LMS Quiz", quiz, ["total_marks", "passing_percentage", "lesson", "course"], as_dict=1
)
score_out_of = quiz_details.total_marks
percentage = (score / score_out_of) * 100 percentage = (score / score_out_of) * 100
else:
result["is_correct"] = 0
is_open_ended = True
result["answer"] = re.sub(
r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"]
)
submission = frappe.get_doc( submission = frappe.get_doc(
{ {
@@ -139,128 +173,51 @@ def quiz_summary(quiz, results):
"submission": submission.name, "submission": submission.name,
"pass": percentage == quiz_details.passing_percentage, "pass": percentage == quiz_details.passing_percentage,
"percentage": percentage, "percentage": percentage,
"is_open_ended": is_open_ended,
} }
@frappe.whitelist() def _save_file(match):
def save_quiz( data = match.group(1).split("data:")[1]
quiz_title, headers, content = data.split(",")
passing_percentage, mtype = headers.split(";", 1)[0]
questions,
max_attempts=0,
quiz=None,
show_answers=1,
show_submission_history=0,
):
if not has_course_moderator_role() or not has_course_instructor_role():
return
values = { if isinstance(content, str):
"title": quiz_title, content = content.encode("utf-8")
"passing_percentage": passing_percentage, if b"," in content:
"max_attempts": max_attempts, content = content.split(b",")[1]
"show_answers": show_answers,
"show_submission_history": show_submission_history, try:
} content = safe_b64decode(content)
except BinasciiError:
frappe.flags.has_dataurl = True
return f'<img src="#broken-image" alt="{get_corrupted_image_msg()}"'
if "filename=" in headers:
filename = headers.split("filename=")[-1]
filename = safe_decode(filename).split(";", 1)[0]
if quiz:
frappe.db.set_value("LMS Quiz", quiz, values)
update_questions(quiz, questions)
return quiz
else: else:
doc = frappe.new_doc("LMS Quiz") filename = get_random_filename(content_type=mtype)
doc.update(values)
doc.save()
update_questions(doc.name, questions)
return doc.name
_file = frappe.get_doc(
def update_questions(quiz, questions):
questions = json.loads(questions)
delete_questions(quiz, questions)
add_questions(quiz, questions)
frappe.db.set_value("LMS Quiz", quiz, "total_marks", set_total_marks(quiz, questions))
def delete_questions(quiz, questions):
existing_questions = frappe.get_all(
"LMS Quiz Question",
{ {
"parent": quiz, "doctype": "File",
}, "file_name": filename,
pluck="name", "content": content,
) "decode": False,
"is_private": False,
current_questions = [question.get("question_name") for question in questions]
for question in existing_questions:
if question not in current_questions:
frappe.db.delete("LMS Quiz Question", question)
def add_questions(quiz, questions):
for index, question in enumerate(questions):
question = frappe._dict(question)
if question.question_name:
doc = frappe.get_doc("LMS Quiz Question", question.question_name)
else:
doc = frappe.new_doc("LMS Quiz Question")
doc.update(
{
"parent": quiz,
"parenttype": "LMS Quiz",
"parentfield": "questions",
"idx": index + 1,
} }
) )
_file.save(ignore_permissions=True)
file_url = _file.unique_url
frappe.flags.has_dataurl = True
doc.update({"question": question.question, "marks": question.marks}) return f'<img src="{file_url}"'
doc.save()
@frappe.whitelist() def get_corrupted_image_msg():
def save_question(quiz, values, index): return _("Image: Corrupted Data Stream")
values = frappe._dict(json.loads(values))
if values.get("name"):
doc = frappe.get_doc("LMS Question", values.get("name"))
else:
doc = frappe.new_doc("LMS Question")
doc.update(
{
"question": values.question,
"type": values["type"],
}
)
for num in range(1, 5):
if values.get(f"option_{num}"):
doc.update(
{
f"option_{num}": values[f"option_{num}"],
f"is_correct_{num}": values[f"is_correct_{num}"],
}
)
if values.get(f"explanation_{num}"):
doc.update(
{
f"explanation_{num}": values[f"explanation_{num}"],
}
)
if values.get(f"possibility_{num}"):
doc.update(
{
f"possibility_{num}": values[f"possibility_{num}"],
}
)
doc.save()
return doc.name
@frappe.whitelist() @frappe.whitelist()
@@ -318,9 +275,3 @@ def check_input_answers(question, answer):
return 1 return 1
return 0 return 0
@frappe.whitelist()
def get_user_quizzes():
filters = {} if has_course_moderator_role() else {"owner": frappe.session.user}
return frappe.get_all("LMS Quiz", filters=filters, fields=["name", "title"])

View File

@@ -9,7 +9,8 @@
"column_break_qcpo", "column_break_qcpo",
"marks", "marks",
"section_break_huup", "section_break_huup",
"question_detail" "question_detail",
"type"
], ],
"fields": [ "fields": [
{ {
@@ -44,12 +45,21 @@
{ {
"fieldname": "section_break_huup", "fieldname": "section_break_huup",
"fieldtype": "Section Break" "fieldtype": "Section Break"
},
{
"fetch_from": "question.type",
"fieldname": "type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "Choices\nUser Input\nOpen Ended",
"read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-07-29 15:10:09.662715", "modified": "2024-10-07 15:01:38.800906",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Quiz Question", "name": "LMS Quiz Question",

View File

@@ -11,6 +11,7 @@
"answer", "answer",
"column_break_flus", "column_break_flus",
"marks", "marks",
"marks_out_of",
"is_correct" "is_correct"
], ],
"fields": [ "fields": [
@@ -33,8 +34,7 @@
"fieldname": "is_correct", "fieldname": "is_correct",
"fieldtype": "Check", "fieldtype": "Check",
"in_list_view": 1, "in_list_view": 1,
"label": "Is Correct", "label": "Is Correct"
"read_only": 1
}, },
{ {
"fieldname": "section_break_fztv", "fieldname": "section_break_fztv",
@@ -54,14 +54,20 @@
"fieldname": "marks", "fieldname": "marks",
"fieldtype": "Int", "fieldtype": "Int",
"in_list_view": 1, "in_list_view": 1,
"label": "Marks", "label": "Marks"
},
{
"fieldname": "marks_out_of",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Marks out of",
"read_only": 1 "read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-05-17 17:38:51.760653", "modified": "2024-10-07 17:28:38.597472",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Quiz Result", "name": "LMS Quiz Result",

View File

@@ -6,6 +6,7 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"quiz", "quiz",
"quiz_title",
"course", "course",
"column_break_3", "column_break_3",
"member", "member",
@@ -39,7 +40,6 @@
"fieldtype": "Int", "fieldtype": "Int",
"in_list_view": 1, "in_list_view": 1,
"label": "Score", "label": "Score",
"read_only": 1,
"reqd": 1 "reqd": 1
}, },
{ {
@@ -95,7 +95,6 @@
"fieldtype": "Int", "fieldtype": "Int",
"label": "Percentage", "label": "Percentage",
"non_negative": 1, "non_negative": 1,
"read_only": 1,
"reqd": 1 "reqd": 1
}, },
{ {
@@ -105,12 +104,19 @@
"non_negative": 1, "non_negative": 1,
"read_only": 1, "read_only": 1,
"reqd": 1 "reqd": 1
},
{
"fetch_from": "quiz.title",
"fieldname": "quiz_title",
"fieldtype": "Data",
"label": "Quiz Title",
"read_only": 1
} }
], ],
"in_create": 1, "in_create": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-02-27 13:01:53.611726", "modified": "2024-10-07 16:52:04.162521",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Quiz Submission", "name": "LMS Quiz Submission",

View File

@@ -1,15 +1,28 @@
# Copyright (c) 2021, FOSS United and contributors # Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt # For license information, please see license.txt
# import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cint
from frappe import _
class LMSQuizSubmission(Document): class LMSQuizSubmission(Document):
def before_insert(self): def validate(self):
if not self.percentage: self.validate_marks()
self.set_percentage() self.set_percentage()
def validate_marks(self):
for row in self.result:
if cint(row.marks) > cint(row.marks_out_of):
frappe.throw(
_(
"Marks for question number {0} cannot be greater than the marks allotted for that question."
).format(row.idx)
)
else:
self.score += cint(row.marks)
def set_percentage(self): def set_percentage(self):
if self.score and self.score_out_of: if self.score and self.score_out_of:
self.percentage = (self.score / self.score_out_of) * 100 self.percentage = (self.score / self.score_out_of) * 100

View File

@@ -2,6 +2,28 @@
// For license information, please see license.txt // For license information, please see license.txt
frappe.ui.form.on("LMS Settings", { frappe.ui.form.on("LMS Settings", {
// refresh: function(frm) { setup: function (frm) {
// } frappe.call({
method: "lms.lms.doctype.lms_settings.lms_settings.check_payments_app",
callback: (data) => {
if (!data.message) {
frm.set_df_property("payment_section", "hidden", 1);
frm.trigger("set_no_payments_app_html");
} else {
frm.set_df_property("no_payments_app", "hidden", 1);
}
},
});
},
set_no_payments_app_html(frm) {
frm.get_field("payments_app_is_not_installed").html(`
<div class="alert alert-warning">
Please install the
<a target="_blank" style="color: var(--alert-text-warning); background: var(--alert-bg-warning);" href="https://frappecloud.com/marketplace/apps/payments">
Payments app
</a>
to enable payment gateway.
`);
},
}); });

View File

@@ -42,14 +42,15 @@
"mentor_request_status_update", "mentor_request_status_update",
"payment_settings_tab", "payment_settings_tab",
"payment_section", "payment_section",
"razorpay_key", "payment_gateway",
"razorpay_secret",
"apply_gst",
"column_break_cfcv",
"default_currency", "default_currency",
"exception_country",
"column_break_cfcv",
"apply_gst",
"show_usd_equivalent", "show_usd_equivalent",
"apply_rounding", "apply_rounding",
"exception_country", "no_payments_app",
"payments_app_is_not_installed",
"email_templates_tab", "email_templates_tab",
"certification_template", "certification_template",
"batch_confirmation_template", "batch_confirmation_template",
@@ -147,16 +148,6 @@
"fieldname": "column_break_cfcv", "fieldname": "column_break_cfcv",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fieldname": "razorpay_key",
"fieldtype": "Data",
"label": "Razorpay Key"
},
{
"fieldname": "razorpay_secret",
"fieldtype": "Password",
"label": "Razorpay Secret"
},
{ {
"default": "0", "default": "0",
"fieldname": "apply_gst", "fieldname": "apply_gst",
@@ -173,7 +164,7 @@
"depends_on": "show_usd_equivalent", "depends_on": "show_usd_equivalent",
"fieldname": "exception_country", "fieldname": "exception_country",
"fieldtype": "Table MultiSelect", "fieldtype": "Table MultiSelect",
"label": "Maintain Original Currency", "label": "Primary Countries",
"options": "Payment Country" "options": "Payment Country"
}, },
{ {
@@ -331,12 +322,26 @@
"fieldname": "custom_signup_content", "fieldname": "custom_signup_content",
"fieldtype": "HTML Editor", "fieldtype": "HTML Editor",
"label": "Custom Signup Content" "label": "Custom Signup Content"
},
{
"fieldname": "payment_gateway",
"fieldtype": "Data",
"label": "Payment Gateway"
},
{
"fieldname": "no_payments_app",
"fieldtype": "Section Break"
},
{
"fieldname": "payments_app_is_not_installed",
"fieldtype": "HTML",
"label": "Payments app is not installed"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2024-09-23 17:57:01.350020", "modified": "2024-10-01 12:15:49.800242",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Settings", "name": "LMS Settings",

View File

@@ -39,3 +39,32 @@ class LMSSettings(Document):
frappe.bold("Course Evaluator"), 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
View 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

View File

@@ -908,39 +908,6 @@ def get_upcoming_evals(student, courses):
return upcoming_evals 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): def check_multicurrency(amount, currency, country=None, amount_usd=None):
settings = frappe.get_single("LMS Settings") settings = frappe.get_single("LMS Settings")
show_usd_equivalent = settings.show_usd_equivalent show_usd_equivalent = settings.show_usd_equivalent
@@ -998,145 +965,6 @@ def apply_gst(amount, country=None):
return amount, gst_applied 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): def create_membership(course, payment):
membership = frappe.new_doc("LMS Enrollment") membership = frappe.new_doc("LMS Enrollment")
membership.update( membership.update(
@@ -1146,24 +974,6 @@ def create_membership(course, payment):
return f"/lms/courses/{course}/learn/1-1" 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"): def get_current_exchange_rate(source, target="USD"):
url = f"https://api.frankfurter.app/latest?from={source}&to={target}" url = f"https://api.frankfurter.app/latest?from={source}&to={target}"
@@ -1765,10 +1575,11 @@ def get_order_summary(doctype, docname, country=None):
details.amount, details.currency = check_multicurrency( details.amount, details.currency = check_multicurrency(
details.amount, details.currency, country, details.amount_usd details.amount, details.currency, country, details.amount_usd
) )
details.original_amount = details.amount
details.original_amount_formatted = fmt_money(details.amount, 0, details.currency) details.original_amount_formatted = fmt_money(details.amount, 0, details.currency)
if details.currency == "INR": 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.gst_amount_formatted = fmt_money(details.gst_applied, 0, details.currency)
details.total_amount_formatted = fmt_money(details.amount, 0, details.currency) details.total_amount_formatted = fmt_money(details.amount, 0, details.currency)
@@ -1826,3 +1637,89 @@ def publish_notifications(doc, method):
frappe.publish_realtime( frappe.publish_realtime(
"publish_lms_notifications", user=doc.for_user, after_commit=True "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)

File diff suppressed because it is too large Load Diff