Compare commits

..

51 Commits

Author SHA1 Message Date
Frappe PR Bot
9b3906359b chore(release): Bumped to Version 2.25.0 2025-03-03 10:20:45 +00:00
Jannat Patel
4224580d6f Merge pull request #1351 from pateljannat/billing-flow-changes
fix: redirect to FC dashboard when login to FC
2025-03-03 14:59:21 +05:30
Jannat Patel
07d30647d8 chore: upgrading actions/cache to v4 for ci tests 2025-03-03 13:58:39 +05:30
Jannat Patel
263096fc77 fix: changed frappe cloud login flow 2025-03-03 13:53:16 +05:30
Jannat Patel
b510cbce7f Merge pull request #1347 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-03-03 10:13:26 +05:30
Jannat Patel
0b84dc3266 Merge pull request #1346 from frappe/pot_develop_2025-02-28
chore: update POT file
2025-03-03 10:13:13 +05:30
Md Hussain Nagaria
7ee7b95eb5 Merge pull request #1350 from frappe/tz-autocomplete
feat: timezone autocomplete in live class & misc fixes
2025-03-03 06:35:07 +05:30
Hussain Nagaria
83b8bdde45 fix: transform tabIndex query param to number 2025-03-03 06:22:19 +05:30
Hussain Nagaria
1b5dd15b90 feat(LiveClass): timezone autocomplete field 2025-03-03 06:19:06 +05:30
Hussain Nagaria
47c224fcad chore: remove unused imports 2025-03-03 06:01:02 +05:30
Jannat Patel
1c866f40eb chore: Persian translations 2025-03-01 02:00:07 +05:30
frappe-pr-bot
1861aabaca chore: update POT file 2025-02-28 16:04:26 +00:00
Jannat Patel
cd8fb6eb38 Merge pull request #1342 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-02-28 10:42:18 +05:30
Jannat Patel
21d05d3731 chore: Bosnian translations 2025-02-28 01:36:22 +05:30
Jannat Patel
7c953925f9 chore: Bosnian translations 2025-02-27 01:40:12 +05:30
Jannat Patel
33a4bbbe47 chore: Persian translations 2025-02-27 01:40:10 +05:30
Frappe PR Bot
dfb82570ea chore(release): Bumped to Version 2.24.0 2025-02-26 04:50:44 +00:00
Jannat Patel
e712d6ae42 Merge pull request #1334 from pateljannat/paid-certificate-on-courses
feat: paid certifications on courses
2025-02-25 14:47:07 +05:30
Jannat Patel
6ffc953370 test: removed course expiry from test 2025-02-25 14:33:53 +05:30
Jannat Patel
63bf6a5574 fix: polished the course certification flow 2025-02-25 12:46:35 +05:30
Jannat Patel
1e73fc5751 Merge pull request #1338 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-02-25 10:50:30 +05:30
Jannat Patel
65604a0b88 chore: Esperanto translations 2025-02-25 01:05:38 +05:30
Jannat Patel
5a1a39f5f5 chore: Bosnian translations 2025-02-25 01:05:36 +05:30
Jannat Patel
d22576c85c chore: Persian translations 2025-02-25 01:05:35 +05:30
Jannat Patel
b7e5332c38 chore: Chinese Simplified translations 2025-02-25 01:05:33 +05:30
Jannat Patel
ed8570fb88 chore: Turkish translations 2025-02-25 01:05:32 +05:30
Jannat Patel
ce69e6634d chore: Swedish translations 2025-02-25 01:05:31 +05:30
Jannat Patel
274db20c60 chore: Russian translations 2025-02-25 01:05:29 +05:30
Jannat Patel
3d72072f1f chore: Polish translations 2025-02-25 01:05:28 +05:30
Jannat Patel
ed156c09d7 chore: Hungarian translations 2025-02-25 01:05:27 +05:30
Jannat Patel
fda3a1a468 chore: German translations 2025-02-25 01:05:25 +05:30
Jannat Patel
c261387635 chore: Arabic translations 2025-02-25 01:05:24 +05:30
Jannat Patel
7a2fa4dae8 chore: Spanish translations 2025-02-25 01:05:22 +05:30
Jannat Patel
b0c41958d9 chore: French translations 2025-02-25 01:05:21 +05:30
Jannat Patel
4f1dcbfb78 feat: eval and certification flow with purchased certificate 2025-02-24 19:15:26 +05:30
Jannat Patel
dc9ed099d0 Merge pull request #1335 from frappe/pot_develop_2025-02-21
chore: update POT file
2025-02-24 10:38:54 +05:30
Md Hussain Nagaria
95255d44a9 feat(batch): track active tab in URL/route (#1337)
* chore: remove defineModel imports

* it is a compiler macro now, so no longer needs to be imported

* feat(batch): track active tab in URL/route

Fixes #1336

* style: lint
2025-02-22 22:30:14 +05:30
Hussain Nagaria
5a94e8df75 style: lint 2025-02-22 22:23:40 +05:30
Hussain Nagaria
015e3f8490 feat(batch): track active tab in URL/route
Fixes #1336
2025-02-22 22:21:26 +05:30
Hussain Nagaria
558601f02b chore: remove defineModel imports
* it is a compiler macro now, so no longer needs to be imported
2025-02-22 21:58:50 +05:30
frappe-pr-bot
461d96a079 chore: update POT file 2025-02-21 16:04:10 +00:00
Jannat Patel
bacfaf4a71 feat: paid certifications on courses 2025-02-21 19:12:20 +05:30
Jannat Patel
0678def698 Merge pull request #1330 from pateljannat/markdown-links
fix: link issue in lesson
2025-02-20 16:37:33 +05:30
Jannat Patel
07b0a0af51 test: fixed lesson content test 2025-02-20 16:31:09 +05:30
Jannat Patel
f12f6cb720 fix: link issue in lesson 2025-02-20 15:08:14 +05:30
Jannat Patel
4e6c1478f9 Merge pull request #1328 from pateljannat/reschedule-evals
feat: cancel evaluations
2025-02-20 10:34:08 +05:30
Jannat Patel
f9fd36f77e feat: cancel evaluations 2025-02-19 22:29:24 +05:30
Jannat Patel
db4c7424b3 Merge pull request #1327 from pateljannat/issues-79
fix: misc batch issues
2025-02-19 16:51:42 +05:30
Jannat Patel
9311043190 fix: misc batch issues 2025-02-19 16:40:11 +05:30
Jannat Patel
03915ccfbd fix: only system managers should login to FC 2025-02-19 15:39:13 +05:30
Jannat Patel
c6d59216fd fix: redirect to FC dashboard when login to FC 2025-02-19 15:35:34 +05:30
66 changed files with 7635 additions and 5424 deletions

View File

@@ -39,7 +39,7 @@ jobs:
node-version: '18'
check-latest: true
- name: setup cache for bench
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: ~/bench-cache
key: ${{ runner.os }}

View File

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

View File

@@ -5,7 +5,7 @@ describe("Course Creation", () => {
cy.visit("/lms/courses");
// Create a course
cy.get("header").children().last().children().last().click();
cy.get("button").contains("New").click();
cy.wait(1000);
cy.url().should("include", "/courses/new/edit");
@@ -84,9 +84,8 @@ describe("Course Creation", () => {
cy.wait(1000);
cy.get("label").contains("Title").type("Test Lesson");
cy.get("#content .ce-block").type(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
"{enter}This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
cy.button("Save").click();

View File

@@ -19,13 +19,14 @@
"@editorjs/paragraph": "^2.11.3",
"@editorjs/simple-image": "^1.6.0",
"@editorjs/table": "^2.4.2",
"@vueuse/router": "^12.7.0",
"ace-builds": "^1.36.2",
"apexcharts": "^4.3.0",
"chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.109",
"frappe-ui": "^0.1.112",
"lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0",
"pinia": "^2.0.33",

View File

@@ -65,8 +65,7 @@
<div>
<TrialBanner
v-if="
userResource.data?.user_type == 'System User' &&
userResource.data?.is_fc_site
userResource.data?.is_system_manager && userResource.data?.is_fc_site
"
:isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
/>

View File

@@ -69,7 +69,11 @@
name: batch.data.name,
},
}"
v-else-if="batch.data.paid_batch && batch.data.seats_left"
v-else-if="
batch.data.paid_batch &&
batch.data.seats_left > 0 &&
batch.data.accept_enrollments
"
>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<span>
@@ -80,7 +84,11 @@
<Button
variant="solid"
class="w-full mt-2"
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
v-else-if="
batch.data.allow_self_enrollment &&
batch.data.seats_left &&
batch.data.accept_enrollments
"
@click="enrollInBatch()"
>
{{ __('Enroll Now') }}
@@ -112,6 +120,7 @@ import { useRouter } from 'vue-router'
const router = useRouter()
const user = inject('$user')
const dayjs = inject('$dayjs')
const props = defineProps({
batch: {

View File

@@ -0,0 +1,67 @@
<template>
<div
v-if="
certification.data &&
certification.data.membership &&
certification.data.paid_certificate &&
user.data?.is_student
"
>
<router-link
v-if="!certification.data.membership.purchased_certificate"
:to="{
name: 'Billing',
params: {
type: 'certificate',
name: courseName,
},
}"
>
<Button class="w-full">
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Get Certified') }}
</Button>
</router-link>
<router-link
v-else-if="!certification.data.membership.certficate"
:to="{
name: 'CourseCertification',
params: {
courseName: courseName,
},
}"
>
<Button class="w-full">
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Get Certified') }}
</Button>
</router-link>
</div>
</template>
<script setup>
import { Button, createResource } from 'frappe-ui'
import { inject } from 'vue'
import { GraduationCap } from 'lucide-vue-next'
const user = inject('$user')
const props = defineProps({
courseName: {
type: String,
required: true,
},
})
const certification = createResource({
url: 'lms.lms.api.get_certification_details',
params: {
course: props.courseName,
},
auto: true,
cache: ['certificationData', user.data?.name],
})
</script>

View File

@@ -130,7 +130,7 @@ import {
ComboboxOptions,
ComboboxOption,
} from '@headlessui/vue'
import { Popover, Button } from 'frappe-ui'
import { Popover } from 'frappe-ui'
import { ChevronDown, X } from 'lucide-vue-next'
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'

View File

@@ -100,9 +100,15 @@
<CourseInstructors :instructors="course.instructors" />
</div>
<div class="font-semibold">
<div v-if="course.paid_course" class="font-semibold">
{{ course.price }}
</div>
<div
v-if="course.paid_certificate || course.enable_certification"
class="text-xs text-ink-blue-3 bg-surface-blue-1 py-0.5 px-1 rounded-md"
>
{{ __('Certification') }}
</div>
</div>
</div>
</div>

View File

@@ -6,30 +6,32 @@
class="rounded-t-md min-h-56 w-full"
/>
<div class="p-5">
<div v-if="course.data.price" class="text-2xl font-semibold mb-3">
<div v-if="course.data.paid_course" class="text-2xl font-semibold mb-3">
{{ course.data.price }}
</div>
<router-link
v-if="course.data.membership"
:to="{
name: 'Lesson',
params: {
courseName: course.name,
chapterNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[0]
: 1,
lessonNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[1]
: 1,
},
}"
>
<Button variant="solid" size="md" class="w-full">
<span>
{{ __('Continue Learning') }}
</span>
</Button>
</router-link>
<div v-if="course.data.membership" class="space-y-2">
<router-link
:to="{
name: 'Lesson',
params: {
courseName: course.name,
chapterNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[0]
: 1,
lessonNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[1]
: 1,
},
}"
>
<Button variant="solid" size="md" class="w-full">
<span>
{{ __('Continue Learning') }}
</span>
</Button>
</router-link>
<CertificationLinks :courseName="course.data.name" />
</div>
<router-link
v-else-if="course.data.paid_course"
:to="{
@@ -113,17 +115,36 @@
{{ course.data.rating }} {{ __('Rating') }}
</span>
</div>
<div
v-if="course.data.enable_certification"
class="flex items-center font-semibold text-ink-gray-9"
>
<GraduationCap class="h-4 w-4 stroke-2" />
<span class="ml-2">
{{ __('Certificate of Completion') }}
</span>
</div>
<div
v-if="course.data.paid_certificate"
class="flex items-center font-semibold text-ink-gray-9"
>
<GraduationCap class="h-4 w-4 stroke-2" />
<span class="ml-2">
{{ __('Paid Certificate after Evaluation') }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { BookOpen, Users, Star } from 'lucide-vue-next'
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
import { computed, inject } from 'vue'
import { Button, createResource } from 'frappe-ui'
import { Button, createResource, Tooltip } from 'frappe-ui'
import { showToast, formatAmount } from '@/utils/'
import { capture } from '@/telemetry'
import { useRouter } from 'vue-router'
import CertificationLinks from '@/components/CertificationLinks.vue'
const router = useRouter()
const user = inject('$user')

View File

@@ -38,7 +38,7 @@
<div class="flex mt-2">
<Star
v-for="index in 5"
class="h-5 w-5 text-ink-gray-2 rounded-sm mr-2"
class="h-5 w-5 text-ink-gray-1 rounded-sm mr-2"
:class="
index <= Math.ceil(review.rating)
? 'fill-orange-500'

View File

@@ -0,0 +1,23 @@
<template>
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.75"
y="0.75"
width="30.5"
height="30.5"
rx="6.25"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M24.5011 14.1124C23.3954 12.4873 21.532 11.5477 19.594 11.6747C18.7616 10.1384 17.2211 9.12267 15.4198 9.0084C14.1651 8.93222 12.8979 9.3766 11.9165 10.24C11.2456 10.8367 10.7611 11.5223 10.463 12.2968C10.289 12.7539 9.89151 13.0459 9.46912 13.0459H6.5V15.5852H9.46912C10.9226 15.5852 12.2271 14.6584 12.7737 13.2237C12.9227 12.8301 13.1712 12.4873 13.5439 12.1571C14.0284 11.7255 14.662 11.4969 15.2583 11.535C16.1528 11.5985 16.7863 12.0175 17.1839 12.538C17.6063 13.0205 17.8423 13.7696 17.979 14.5187C18.774 14.2902 19.6437 14.0997 20.476 14.2394C21.1593 14.3536 21.7929 14.7218 22.2525 15.2678C22.327 15.3567 22.4016 15.4456 22.4637 15.5471C23.06 16.4232 23.1718 17.5024 22.7743 18.5689C22.414 19.5592 21.0847 20.4607 19.9791 20.4607H11.3326C10.1524 20.4607 9.18339 19.5592 9.03432 18.4038H6.54969C6.71119 20.9686 8.78585 23 11.3326 23H19.9915C22.1283 23 24.3769 21.451 25.1098 19.4704C25.7931 17.6167 25.5695 15.6614 24.5135 14.0997L24.5011 14.1124Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex space-x-4 border rounded-md p-2">
<Avatar :image="job.company_logo" :label="job.job_title" size="2xl" />
<img :src="job.company_logo" class="size-10 rounded-full object-contain" />
<div class="flex flex-col space-y-2 flex-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-ink-gray-9">

View File

@@ -36,7 +36,7 @@
<FormControl
v-model="member.first_name"
:placeholder="__('First Name')"
type="test"
type="text"
class="w-full"
/>
<Button @click="addMember()" variant="subtle">

View File

@@ -31,7 +31,7 @@
</template>
<script setup>
import { Dialog, createResource } from 'frappe-ui'
import { ref, defineModel } from 'vue'
import { ref } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'

View File

@@ -77,7 +77,7 @@ import {
FormControl,
Switch,
} from 'frappe-ui'
import { defineModel, reactive, watch } from 'vue'
import { reactive, watch } from 'vue'
import { showToast, getFileSize } from '@/utils/'
import { capture } from '@/telemetry'
import { FileText, X } from 'lucide-vue-next'

View File

@@ -35,7 +35,7 @@
</template>
<script setup>
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
import { reactive, defineModel } from 'vue'
import { reactive } from 'vue'
import { showToast, singularize } from '@/utils'
const topics = defineModel('reloadTopics')

View File

@@ -94,7 +94,7 @@ import {
createResource,
TextEditor,
} from 'frappe-ui'
import { reactive, watch, defineModel } from 'vue'
import { reactive, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { getFileSize, showToast, escapeHTML } from '@/utils'

View File

@@ -25,7 +25,15 @@
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Date') }}
</div>
<FormControl type="date" v-model="evaluation.date" />
<FormControl
type="date"
v-model="evaluation.date"
:min="
dayjs()
.add(dayjs.duration({ days: 1 }))
.format('YYYY-MM-DD')
"
/>
</div>
<div v-if="slots.data?.length">
<div class="mb-1.5 text-sm text-ink-gray-5">
@@ -58,7 +66,7 @@
</template>
<script setup>
import { Dialog, createResource, Select, FormControl } from 'frappe-ui'
import { defineModel, reactive, watch, inject } from 'vue'
import { reactive, watch, inject } from 'vue'
import { createToast, formatTime } from '@/utils/'
const user = inject('$user')
@@ -161,6 +169,11 @@ const getCourses = () => {
})
}
}
if (courses.length == 1) {
evaluation.course = courses[0].value
}
return courses
}

View File

@@ -1,94 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
title: __('Login to Frappe Cloud'),
actions: [
{
label: __('Verify'),
variant: 'solid',
onClick: (close) => {
verifyCode(close)
},
},
],
}"
>
<template #body-content>
<div>
<p>
{{ __('We have sent the verificaton code to your email id ') }}
<b>{{ props.email }}</b>
</p>
<FormControl
v-model="code"
:label="__('Verification Code')"
class="mb-4"
/>
<p>
{{ __("Didn't receive the code?") }}
<a href="#" @click="resendCode">{{ __('Resend') }}</a>
</p>
</div>
</template>
</Dialog>
</template>
<script setup>
import { call, Dialog } from 'frappe-ui'
import { showToast } from '@/utils'
const show = defineModel()
const code = ref('')
const props = defineProps({
email: {
type: String,
required: true,
},
})
const verifyCode = (close) => {
if (!code.value) {
return
}
call(
'frappe.integrations.frappe_providers.frappecloud_billing.verify_verification_code',
{
verification_code: code.value,
route: window.route,
}
)
.then((data) => {
if (data.message.login_token) {
close()
window.open(
`${frappeCloudBaseEndpoint}/api/method/press.api.developer.saas.login_to_fc?token=${data.message.login_token}`,
'_blank'
)
showToast(
__('Frappe Cloud Login Successful'),
`<p>${__('You will be redirected to Frappe Cloud soon.')}</p><p>${__(
"If you haven't been redirected,"
)} <a href="${frappeCloudBaseEndpoint}/api/method/press.api.developer.saas.login_to_fc?token=${
data.message.login_token
}" target="_blank">${__('Click here to login')}</a></p>`,
'check'
)
} else {
showToast(__('Login failed'), __('Please try again'), 'x')
}
})
.catch((err) => {
showToast(__('Login failed'), __('Please try again'), 'x')
})
}
const resendCode = () => {
call(
'frappe.integrations.frappe_providers.frappecloud_billing.send_verification_code'
).catch((err) => {
showToast(__('Failed to resend code'), __('Please try again'), 'x')
})
}
</script>

View File

@@ -66,7 +66,7 @@
<script setup>
import { Dialog, FileUploader, Button, createResource } from 'frappe-ui'
import { FileText } from 'lucide-vue-next'
import { ref, inject, defineModel } from 'vue'
import { ref, inject } from 'vue'
import { createToast, getFileSize } from '@/utils/'
const resume = ref(null)

View File

@@ -39,13 +39,19 @@
:required="true"
/>
</Tooltip>
<FormControl
v-model="liveClass.timezone"
type="select"
:options="getTimezoneOptions()"
:label="__('Timezone')"
:required="true"
/>
<div class="space-y-1.5">
<label class="block text-ink-gray-5 text-xs" for="batchTimezone">
{{ __('Timezone') }}
<span class="text-ink-red-3">*</span>
</label>
<Autocomplete
@update:modelValue="(opt) => (liveClass.timezone = opt.value)"
:modelValue="liveClass.timezone"
:options="getTimezoneOptions()"
:required="true"
/>
</div>
</div>
<div>
<FormControl
@@ -83,18 +89,14 @@
</template>
<script setup>
import {
Input,
DatePicker,
Select,
Textarea,
Dialog,
createResource,
Tooltip,
FormControl,
Autocomplete,
} from 'frappe-ui'
import { reactive, inject } from 'vue'
import { getTimezones, createToast } from '@/utils/'
import { Info } from 'lucide-vue-next'
const liveClasses = defineModel('reloadLiveClasses')
const show = defineModel()

View File

@@ -33,7 +33,7 @@
</template>
<script setup>
import { Dialog, Textarea, createResource } from 'frappe-ui'
import { defineModel, reactive } from 'vue'
import { reactive } from 'vue'
import Rating from '@/components/Controls/Rating.vue'
import { createToast } from '@/utils/'

View File

@@ -4,12 +4,18 @@
<div class="text-lg font-semibold">
{{ __('Upcoming Evaluations') }}
</div>
<Button @click="openEvalModal">
<Button
v-if="
!upcoming_evals.data?.length ||
upcoming_evals.length == courses.length
"
@click="openEvalModal"
>
{{ __('Schedule Evaluation') }}
</Button>
</div>
<div v-if="upcoming_evals.data?.length">
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-3 gap-4">
<div v-for="evl in upcoming_evals.data">
<div class="border rounded-md p-3">
<div class="font-semibold mb-3">
@@ -28,17 +34,39 @@
</span>
</div>
<div class="flex items-center">
<UserCog2 class="w-4 h-4 stroke-1.5" />
<span class="ml-2 font-medium">
<GraduationCap class="w-4 h-4 stroke-1.5" />
<span class="ml-2">
{{ evl.evaluator_name }}
</span>
</div>
<div class="flex items-center justify-between space-x-2 mt-4">
<Button
v-if="evl.google_meet_link"
@click="openEvalCall(evl)"
class="w-full"
>
<template #prefix>
<HeadsetIcon class="w-4 h-4 stroke-1.5" />
</template>
{{ __('Join Call') }}
</Button>
<Button
v-if="evl.date > dayjs().format()"
@click="cancelEvaluation(evl)"
class="w-full"
>
<template #prefix>
<Ban class="w-4 h-4 stroke-1.5" />
</template>
{{ __('Cancel') }}
</Button>
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('No upcoming evaluations.') }}
{{ __('Please schedule an evaluation to get certified.') }}
</div>
</div>
<EvaluationModal
@@ -50,15 +78,23 @@
/>
</template>
<script setup>
import { Calendar, Clock, UserCog2 } from 'lucide-vue-next'
import { inject, ref } from 'vue'
import {
Ban,
Calendar,
Clock,
GraduationCap,
HeadsetIcon,
} from 'lucide-vue-next'
import { inject, ref, getCurrentInstance } from 'vue'
import { formatTime } from '../utils'
import { Button, createResource } from 'frappe-ui'
import { Button, createResource, call } from 'frappe-ui'
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
const dayjs = inject('$dayjs')
const user = inject('$user')
const showEvalModal = ref(false)
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
const props = defineProps({
batch: {
@@ -77,10 +113,10 @@ const props = defineProps({
const upcoming_evals = createResource({
url: 'lms.lms.utils.get_upcoming_evals',
cache: ['upcoming_evals', user.data.name],
params: {
student: user.data.name,
courses: props.courses.map((course) => course.course),
batch: props.batch,
},
auto: true,
})
@@ -88,4 +124,32 @@ const upcoming_evals = createResource({
function openEvalModal() {
showEvalModal.value = true
}
const openEvalCall = (evl) => {
window.open(evl.google_meet_link, '_blank')
}
const cancelEvaluation = (evl) => {
$dialog({
title: __('Cancel this evaluation?'),
message: __(
'Are you sure you want to cancel this evaluation? This action cannot be undone.'
),
actions: [
{
label: __('Cancel'),
theme: 'red',
variant: 'solid',
onClick(close) {
call('lms.lms.api.cancel_evaluation', { evaluation: evl }).then(
() => {
upcoming_evals.reload()
}
)
close()
},
},
],
})
}
</script>

View File

@@ -59,22 +59,21 @@
v-if="userResource.data?.is_moderator"
v-model="showSettingsModal"
/>
<FCVerfiyCodeModal v-if="showFCLoginDialog" :email="verificationEmail" />
</template>
<script setup>
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { sessionStore } from '@/stores/session'
import { call, Dropdown } from 'frappe-ui'
import { Dropdown } from 'frappe-ui'
import Apps from '@/components/Apps.vue'
import { useRouter } from 'vue-router'
import { convertToTitleCase, showToast } from '@/utils'
import { convertToTitleCase } from '@/utils'
import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings'
import { markRaw, watch, ref, onMounted, computed } from 'vue'
import SettingsModal from '@/components/Modals/Settings.vue'
import { createDialog } from '@/utils/dialogs'
import FCVerfiyCodeModal from './Modals/FCVerfiyCodeModal.vue'
import SettingsModal from '@/components/Modals/Settings.vue'
import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue'
import {
ChevronDown,
LogIn,
@@ -83,7 +82,6 @@ import {
User,
Settings,
Sun,
LogInIcon,
} from 'lucide-vue-next'
const router = useRouter()
@@ -93,11 +91,8 @@ const settingsStore = useSettings()
let { isLoggedIn } = sessionStore()
const showSettingsModal = ref(false)
const theme = ref('light')
const $dialog = createDialog
const frappeCloudBaseEndpoint = 'https://frappecloud.com'
const showFCLoginDialog = ref(false)
const verificationEmail = ref(null)
const $dialog = createDialog
const props = defineProps({
isCollapsed: {
@@ -166,15 +161,29 @@ const userDropdownOptions = computed(() => {
},
},
{
icon: LogInIcon,
icon: FrappeCloudIcon,
label: 'Login to Frappe Cloud',
onClick: () => {
initiateRequestForLoginToFrappeCloud()
$dialog({
title: __('Login to Frappe Cloud?'),
message: __(
'Are you sure you want to login to your Frappe Cloud dashboard?'
),
actions: [
{
label: __('Confirm'),
variant: 'solid',
onClick(close) {
loginToFrappeCloud()
close()
},
},
],
})
},
condition: () => {
return (
userResource.data?.user_type == 'System User' &&
userResource.data?.is_fc_site
userResource.data?.is_system_manager && userResource.data?.is_fc_site
)
},
},
@@ -203,47 +212,11 @@ const userDropdownOptions = computed(() => {
]
})
const initiateRequestForLoginToFrappeCloud = () => {
$dialog({
title: __('Login to Frappe Cloud?'),
message: __(
'Are you sure you want to login to your Frappe Cloud dashboard?'
),
actions: [
{
label: __('Confirm'),
variant: 'solid',
onClick(close) {
requestLoginToFC()
close()
},
},
],
})
}
const requestLoginToFC = () => {
call(
'frappe.integrations.frappe_providers.frappecloud_billing.send_verification_code'
)
.then((data) => {
if (data.message.is_user_logged_in) {
window.open(
`${frappeCloudBaseEndpoint}${data.message.redirect_to}`,
'_blank'
)
return
} else {
showFCLoginDialog.value = true
verificationEmail.value = data.message.email
}
})
.catch((err) => {
showToast(
__('Failed to login to Frappe Cloud'),
__('Please try again'),
'x'
)
})
const loginToFrappeCloud = () => {
let redirect_to = '/dashboard/welcome'
if (userResource.data?.site_info.is_payment_method_added) {
redirect_to = '/dashboard/sites/' + userResource.data.sitename
}
window.open(`${frappeCloudBaseEndpoint}${redirect_to}`, '_blank')
}
</script>

View File

@@ -193,8 +193,9 @@
<BulkCertificates v-model="openCertificateDialog" :batch="batch.data" />
</template>
<script setup>
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
import { computed, inject, ref } from 'vue'
import { useRouteQuery } from '@vueuse/router'
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import {
@@ -270,7 +271,7 @@ const isStudent = computed(() => {
)
})
const tabIndex = ref(0)
const tabIndex = useRouteQuery('tab', 0, { transform: Number })
const tabs = computed(() => {
let batchTabs = []
batchTabs.push({

View File

@@ -12,20 +12,15 @@
v-if="access.data?.access && orderSummary.data"
class="pt-5 pb-10 mx-5"
>
<!-- <div class="mb-5">
<div class="text-lg font-semibold">
{{ __('Address') }}
</div>
</div> -->
<div class="flex flex-col lg:flex-row justify-between">
<div
class="h-fit bg-surface-gray-2 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 text-sm font-medium lg:w-1/4"
class="h-fit bg-surface-gray-2 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 font-medium lg:w-1/3"
>
<div class="flex items-center justify-between space-x-2">
<div class="flex items-baseline justify-between space-y-2">
<div class="text-ink-gray-5">
{{ __('Ordered Item') }}
{{ __('Payment for ') }} {{ type }}:
</div>
<div class="">
<div class="leading-5">
{{ orderSummary.data.title }}
</div>
</div>
@@ -126,7 +121,7 @@
<p class="text-ink-gray-5">
{{
__(
'Make sure to enter the right billing name as the same will be used in your invoice.'
'Make sure to enter the correct billing name as the same will be used in your invoice.'
)
}}
</p>
@@ -140,10 +135,10 @@
<div v-else-if="access.data?.message">
<NotPermitted
:text="access.data.message"
:buttonLabel="
type == 'course' ? 'Checkout Courses' : 'Checkout Batches'
:buttonLabel="type == 'course' ? 'Checkout Course' : 'Checkout Batch'"
:buttonLink="
type == 'course' ? `/lms/courses/${name}` : `/lms/batches/${name}`
"
:buttonLink="type == 'course' ? '/lms/courses' : '/lms/batches'"
/>
</div>
<div v-else-if="!user.data?.name">
@@ -163,7 +158,7 @@ import {
Breadcrumbs,
Tooltip,
} from 'frappe-ui'
import { reactive, inject, onMounted, ref } from 'vue'
import { reactive, inject, onMounted, computed } from 'vue'
import Link from '@/components/Controls/Link.vue'
import NotPermitted from '@/components/NotPermitted.vue'
import { showToast } from '@/utils/'
@@ -193,7 +188,7 @@ const props = defineProps({
const access = createResource({
url: 'lms.lms.api.validate_billing_access',
params: {
type: props.type,
billing_type: props.type,
name: props.name,
},
onSuccess(data) {
@@ -206,7 +201,7 @@ const orderSummary = createResource({
url: 'lms.lms.utils.get_order_summary',
makeParams(values) {
return {
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
docname: props.name,
country: billingDetails.country,
}
@@ -236,22 +231,26 @@ const paymentLink = createResource({
url: 'lms.lms.payments.get_payment_link',
makeParams(values) {
return {
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
docname: props.name,
title: orderSummary.data.title,
amount: orderSummary.data.original_amount,
total_amount: orderSummary.data.amount,
currency: orderSummary.data.currency,
address: billingDetails,
redirect_to: redirectTo.value,
payment_for_certificate: props.type == 'certificate',
}
},
})
const generatePaymentLink = () => {
console.log('called')
paymentLink.submit(
{},
{
validate() {
console.log('validation start')
if (!billingDetails.source) {
return __('Please let us know where you heard about us from.')
}
@@ -330,6 +329,8 @@ const validateAddress = () => {
!states.includes(billingDetails.state)
)
return 'Please enter a valid state with correct spelling and the first letter capitalized.'
console.log('validation address')
}
const showError = (err) => {
@@ -347,4 +348,14 @@ const changeCurrency = (country) => {
billingDetails.country = country
orderSummary.reload()
}
const redirectTo = computed(() => {
if (props.type == 'course') {
return `/lms/courses/${props.name}`
} else if (props.type == 'batch') {
return `/lms/batches/${props.name}`
} else if (props.type == 'certificate') {
return `/lms/courses/${props.name}/certification`
}
})
</script>

View File

@@ -0,0 +1,117 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
</header>
<div class="p-5">
<div v-if="certificate.data && Object.keys(certificate.data).length">
<div class="text-lg text-ink-gray-9 font-semibold mb-1">
{{ __('Certification') }}
</div>
<div class="text-ink-gray-9 text-sm">
{{
__(
'You are already certified for this course. Click on the card below to open your certificate.'
)
}}
</div>
<div
class="border p-3 w-fit min-w-60 rounded-md space-y-2 hover:bg-surface-gray-1 cursor-pointer mt-5"
@click="openCertificate(certificate.data)"
>
<div class="text-ink-gray-9 font-semibold">
{{ courseTitle }}
</div>
<div class="text-sm text-ink-gray-7 font-medium">
{{ __('Issued On') }}:
{{ dayjs(certificate.data.issue_date).format('DD MMM YYYY') }}
</div>
</div>
</div>
<div v-else>
<UpcomingEvaluations v-if="courses.length" :courses="courses" />
</div>
</div>
</template>
<script setup>
import { computed, inject, onMounted, ref } from 'vue'
import { Breadcrumbs, call, createResource } from 'frappe-ui'
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
const courseTitle = ref(null)
const evaluator = ref(null)
const courses = ref([])
const user = inject('$user')
const dayjs = inject('$dayjs')
const props = defineProps({
courseName: {
type: String,
required: true,
},
})
onMounted(() => {
fetchCourseDetails()
})
const certificate = createResource({
url: 'frappe.client.get_value',
params: {
doctype: 'LMS Certificate',
filters: {
member: user.data?.name,
course: props.courseName,
},
fieldname: ['name', 'template', 'issue_date'],
},
auto: true,
cache: [user.data?.name, props.courseName],
})
const fetchCourseDetails = () => {
call('frappe.client.get_value', {
doctype: 'LMS Course',
filters: { name: props.courseName },
fieldname: ['title', 'evaluator'],
}).then((data) => {
courseTitle.value = data.title
evaluator.value = data.evaluator
populateCourses()
})
}
const populateCourses = () => {
courses.value = [
{
course: props.courseName,
title: courseTitle.value,
evaluator: evaluator.value,
},
]
}
const openCertificate = (certificate) => {
window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
certificate.name
}&format=${encodeURIComponent(certificate.template)}`,
'_blank'
)
}
const breadcrumbs = computed(() => [
{
label: __('Courses'),
route: { name: 'Courses' },
},
{
label: courseTitle.value,
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
},
{
label: __('Certification'),
},
])
</script>

View File

@@ -160,7 +160,7 @@
<div class="text-lg font-semibold mt-5 mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-3 gap-10 mb-4">
<div class="grid grid-cols-2 gap-10 mb-4">
<div
v-if="user.data?.is_moderator"
class="flex flex-col space-y-4"
@@ -188,43 +188,48 @@
v-model="course.featured"
:label="__('Featured')"
/>
</div>
<div class="flex flex-col space-y-3">
<FormControl
type="checkbox"
v-model="course.disable_self_learning"
:label="__('Disable Self Enrollment')"
/>
<FormControl
type="checkbox"
v-model="course.enable_certification"
:label="__('Completion Certificate')"
/>
</div>
</div>
</div>
<div class="container border-t">
<div class="text-lg font-semibold mt-5 mb-4">
{{ __('Pricing') }}
<div class="container border-t space-y-4">
<div class="text-lg font-semibold mt-5">
{{ __('Pricing and Certification') }}
</div>
<div class="mb-4">
<div class="grid grid-cols-3">
<FormControl
type="checkbox"
v-model="course.paid_course"
:label="__('Paid Course')"
/>
<FormControl
type="checkbox"
v-model="course.enable_certification"
:label="__('Completion Certificate')"
/>
<FormControl
type="checkbox"
v-model="course.paid_certificate"
:label="__('Paid Certificate')"
/>
</div>
<FormControl
v-model="course.course_price"
:label="__('Course Price')"
class="mb-4"
/>
<FormControl v-model="course.course_price" :label="__('Amount')" />
<Link
doctype="Currency"
v-model="course.currency"
:filters="{ enabled: 1 }"
:label="__('Currency')"
/>
<Link
v-if="course.paid_certificate"
doctype="Course Evaluator"
v-model="course.evaluator"
:label="__('Evaluator')"
/>
</div>
</div>
</div>
@@ -296,8 +301,10 @@ const course = reactive({
disable_self_learning: false,
enable_certification: false,
paid_course: false,
paid_certificate: false,
course_price: '',
currency: '',
evaluator: '',
})
onMounted(() => {
@@ -391,6 +398,7 @@ const courseResource = createResource({
'paid_course',
'featured',
'enable_certification',
'paid_certifiate',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]

View File

@@ -4,6 +4,7 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
<CertificationLinks :courseName="courseName" />
</header>
<div class="grid md:grid-cols-[70%,30%] h-screen">
<div
@@ -197,13 +198,14 @@ import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
import CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRouter, useRoute } from 'vue-router'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { ChevronLeft, ChevronRight, GraduationCap } from 'lucide-vue-next'
import Discussions from '@/components/Discussions.vue'
import { getEditorTools, updateDocumentTitle } from '../utils'
import EditorJS from '@editorjs/editorjs'
import LessonContent from '@/components/LessonContent.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import CertificationLinks from '@/components/CertificationLinks.vue'
const user = inject('$user')
const router = useRouter()

View File

@@ -139,7 +139,7 @@ const renderEditor = (holder) => {
const lesson = reactive({
title: '',
include_in_preview: false,
body: 'Test',
body: '',
instructor_notes: '',
content: '',
})
@@ -294,7 +294,7 @@ const convertToJSON = (lessonData) => {
type: 'upload',
data: {
file_url: video,
file_type: 'video',
file_type: video.split('.').pop(),
},
})
} else if (block.includes('{{ Audio')) {
@@ -303,7 +303,7 @@ const convertToJSON = (lessonData) => {
type: 'upload',
data: {
file_url: audio,
file_type: 'audio',
file_type: audio.split('.').pop(),
},
})
} else if (block.includes('{{ PDF')) {

View File

@@ -28,6 +28,12 @@ const routes = [
component: () => import('@/pages/Lesson.vue'),
props: true,
},
{
path: '/courses/:courseName/certification',
name: 'CourseCertification',
component: () => import('@/pages/CourseCertification.vue'),
props: true,
},
{
path: '/courses/:courseName/learn/:chapterName',
name: 'SCORMChapter',

View File

@@ -50,10 +50,18 @@ export class Markdown {
this.wrapper.innerHTML = this.text
this.wrapper.addEventListener('keydown', (event) => {
const value = event.target.textContent
let value = event.target.textContent
if (event.keyCode === 32 && value.startsWith('#')) {
this.convertToHeader(event, value)
} else if (event.keyCode === 13) {
} else if (event.keyCode == 189) {
this.convertBlock('list', {
style: 'unordered',
})
} else if (/^[a-zA-Z]/.test(event.key)) {
this.convertBlock('paragraph', {
text: value,
})
} else if (event.keyCode === 13 || event.keyCode === 190) {
this.parseContent(event)
}
})
@@ -75,7 +83,11 @@ export class Markdown {
parseContent(event) {
event.preventDefault()
const previousLine = this.wrapper.textContent
let previousLine = this.wrapper.textContent
if (event.keyCode === 190) {
previousLine = previousLine + '.'
}
if (previousLine && this.hasImage(previousLine)) {
this.wrapper.textContent = ''
this.convertBlock('image')
@@ -94,12 +106,12 @@ export class Markdown {
},
],
})
} else if (previousLine && previousLine.startsWith('1. ')) {
} else if (previousLine && previousLine.startsWith('1.')) {
this.convertBlock('list', {
style: 'ordered',
items: [
{
content: previousLine.replace('1. ', ''),
content: previousLine.replace('1.', ''),
},
],
})
@@ -108,6 +120,10 @@ export class Markdown {
this.convertBlock('embed', {
source: previousLine,
})
} else {
this.convertBlock('paragraph', {
text: previousLine,
})
}
}

2441
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
__version__ = "2.23.0"
__version__ = "2.25.0"

View File

@@ -12,6 +12,7 @@ from frappe.translate import get_all_translations
from frappe import _
from frappe.utils import (
get_datetime,
getdate,
cint,
flt,
now,
@@ -22,7 +23,10 @@ from frappe.utils import (
from lms.lms.utils import get_average_rating, get_lesson_count
from xml.dom.minidom import parseString
from lms.lms.doctype.course_lesson.course_lesson import save_progress
from frappe.integrations.frappe_providers.frappecloud_billing import is_fc_site
from frappe.integrations.frappe_providers.frappecloud_billing import (
is_fc_site,
current_site_info,
)
@frappe.whitelist()
@@ -176,6 +180,10 @@ def get_user_info():
user.is_evaluator = "Batch Evaluator" in user.roles
user.is_student = "LMS Student" in user.roles
user.is_fc_site = is_fc_site()
user.is_system_manager = "System Manager" in user.roles
if user.is_fc_site and user.is_system_manager:
user.site_info = current_site_info()
user.sitename = frappe.local.site
return user
@@ -189,24 +197,24 @@ def get_translations():
@frappe.whitelist()
def validate_billing_access(type, name):
def validate_billing_access(billing_type, name):
access = True
message = ""
doctype = "LMS Course" if type == "course" else "LMS Batch"
doctype = "LMS Batch" if billing_type == "batch" else "LMS Course"
if frappe.session.user == "Guest":
access = False
message = _("Please login to continue with payment.")
if type not in ["course", "batch"]:
if access and billing_type not in ["course", "batch", "certificate"]:
access = False
message = _("Module is incorrect.")
if not frappe.db.exists(doctype, name):
if access and not frappe.db.exists(doctype, name):
access = False
message = _("Module Name is incorrect or does not exist.")
if type == "course":
if access and billing_type == "course":
membership = frappe.db.exists(
"LMS Enrollment", {"member": frappe.session.user, "course": name}
)
@@ -214,7 +222,7 @@ def validate_billing_access(type, name):
access = False
message = _("You are already enrolled for this course.")
else:
elif access and billing_type == "batch":
membership = frappe.db.exists(
"LMS Batch Enrollment", {"member": frappe.session.user, "batch": name}
)
@@ -222,6 +230,19 @@ def validate_billing_access(type, name):
access = False
message = _("You are already enrolled for this batch.")
elif access and billing_type == "certificate":
purchased_certificate = frappe.db.exists(
"LMS Enrollment",
{
"course": name,
"member": frappe.session.user,
"purchased_certificate": 1,
},
)
if purchased_certificate:
access = False
message = _("You have already purchased the certificate for this course.")
address = frappe.db.get_value(
"Address",
{"email_id": frappe.session.user},
@@ -369,7 +390,7 @@ def get_evaluator_details(evaluator):
@frappe.whitelist(allow_guest=True)
def get_certified_participants(filters=None, start=0, page_length=30, search=None):
def get_certified_participants(filters=None, start=0, page_length=30):
or_filters = {}
if not filters:
filters = {}
@@ -1228,3 +1249,57 @@ def get_notifications(filters):
@frappe.whitelist(allow_guest=True)
def is_guest_allowed():
return frappe.get_cached_value("LMS Settings", None, "allow_guest_access")
@frappe.whitelist()
def cancel_evaluation(evaluation):
evaluation = frappe._dict(evaluation)
if evaluation.member != frappe.session.user:
return
frappe.db.set_value("LMS Certificate Request", evaluation.name, "status", "Cancelled")
events = frappe.get_all(
"Event Participants",
{
"email": evaluation.member,
},
["parent", "name"],
)
for event in events:
info = frappe.db.get_value("Event", event.parent, ["starts_on", "subject"], as_dict=1)
date = str(info.starts_on).split(" ")[0]
if (
date == str(evaluation.date.format("YYYY-MM-DD"))
and evaluation.member_name in info.subject
):
communication = frappe.db.get_value(
"Communication",
{"reference_doctype": "Event", "reference_name": event.parent},
"name",
)
if communication:
frappe.delete_doc("Communication", communication, ignore_permissions=True)
frappe.delete_doc("Event Participants", event.name, ignore_permissions=True)
frappe.delete_doc("Event", event.parent, ignore_permissions=True)
@frappe.whitelist()
def get_certification_details(course):
membership = None
filters = {"course": course, "member": frappe.session.user}
if frappe.db.exists("LMS Enrollment", filters):
membership = frappe.db.get_value(
"LMS Enrollment",
filters,
["name", "certificate", "purchased_certificate"],
as_dict=1,
)
paid_certificate = frappe.db.get_value("LMS Course", course, "paid_certificate")
return {"membership": membership, "paid_certificate": paid_certificate}

View File

@@ -50,7 +50,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-04-15 18:45:08.614466",
"modified": "2025-02-24 12:17:08.436659",
"modified_by": "Administrator",
"module": "LMS",
"name": "Course Evaluator",

View File

@@ -10,7 +10,6 @@ from datetime import timedelta
from frappe.model.document import Document
from frappe.utils import cint, format_datetime, get_time, add_days, nowdate
from lms.lms.utils import (
get_lessons,
get_lesson_index,
get_lesson_url,
get_quiz_details,
@@ -176,6 +175,10 @@ def create_live_class(
class_details = frappe.get_doc(payload)
class_details.save()
return class_details
else:
frappe.throw(
_("Error creating live class. Please try again. {0}").format(response.text)
)
def authenticate():
@@ -254,17 +257,6 @@ def create_batch(
return doc
@frappe.whitelist()
def fetch_lessons(courses):
lessons = []
courses = json.loads(courses)
for course in courses:
lessons.extend(get_lessons(course.get("course")))
return lessons
@frappe.whitelist()
def add_course(course, parent, name=None, evaluator=None):
frappe.only_for("Moderator")

View File

@@ -122,7 +122,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-17 11:57:02.859109",
"modified": "2025-02-19 16:26:05.902473",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Certificate",
@@ -156,6 +156,7 @@
"create": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,

View File

@@ -122,11 +122,6 @@ def create_certificate(course):
)
else:
expires_after_yrs = int(frappe.db.get_value("LMS Course", course, "expiry"))
expiry_date = None
if expires_after_yrs:
expiry_date = add_years(nowdate(), expires_after_yrs)
default_certificate_template = frappe.db.get_value(
"Property Setter",
{
@@ -148,7 +143,6 @@ def create_certificate(course):
"member": frappe.session.user,
"course": course,
"issue_date": nowdate(),
"expiry_date": expiry_date,
"template": default_certificate_template,
}
)

View File

@@ -16,7 +16,6 @@ class TestLMSCertificate(unittest.TestCase):
"Test Certificate",
{
"enable_certification": 1,
"expiry": 2,
},
)
certificate = create_certificate(course.name)
@@ -24,7 +23,6 @@ class TestLMSCertificate(unittest.TestCase):
self.assertEqual(certificate.member, "Administrator")
self.assertEqual(certificate.course, course.name)
self.assertEqual(certificate.issue_date, nowdate())
self.assertEqual(certificate.expiry_date, add_years(nowdate(), cint(course.expiry)))
frappe.db.delete("LMS Certificate", certificate.name)
frappe.db.delete("LMS Course", course.name)

View File

@@ -24,7 +24,8 @@
"google_meet_link",
"column_break_ddyh",
"start_time",
"end_time"
"end_time",
"status"
],
"fields": [
{
@@ -144,11 +145,19 @@
"fieldtype": "Data",
"hidden": 1,
"label": "Batch Title"
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_standard_filter": 1,
"label": "Status",
"options": "Upcoming\nCompleted\nCancelled",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-09-11 11:19:44.669132",
"modified": "2025-02-19 17:20:02.526294",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Certificate Request",
@@ -204,6 +213,19 @@
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"states": [
{
"color": "Blue",
"title": "Upcoming"
},
{
"color": "Green",
"title": "Completed"
},
{
"color": "Red",
"title": "Cancelled"
}
],
"title_field": "member_name"
}

View File

@@ -40,15 +40,13 @@
"pricing_tab",
"pricing_section",
"paid_course",
"enable_certification",
"paid_certificate",
"evaluator",
"column_break_acoj",
"course_price",
"currency",
"amount_usd",
"certification_tab",
"certification_section",
"enable_certification",
"column_break_rxww",
"expiry",
"tab_4_tab",
"statistics_section",
"enrollments",
@@ -134,22 +132,11 @@
"fieldtype": "Section Break",
"label": "Course Settings"
},
{
"fieldname": "certification_section",
"fieldtype": "Section Break"
},
{
"default": "0",
"fieldname": "enable_certification",
"fieldtype": "Check",
"label": "Enable Certification"
},
{
"default": "0",
"depends_on": "enable_certification",
"fieldname": "expiry",
"fieldtype": "Int",
"label": "Certification Expires After (Years)"
"label": "Completion Certificate"
},
{
"fieldname": "related_courses",
@@ -181,7 +168,6 @@
"fieldtype": "Section Break"
},
{
"depends_on": "paid_course",
"fieldname": "currency",
"fieldtype": "Link",
"label": "Currency",
@@ -195,22 +181,16 @@
"label": "Paid Course"
},
{
"depends_on": "paid_course",
"fieldname": "course_price",
"fieldtype": "Currency",
"label": "Course Price",
"label": "Amount",
"mandatory_depends_on": "paid_course"
},
{
"fieldname": "column_break_rxww",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_acoj",
"fieldtype": "Column Break"
},
{
"depends_on": "paid_course",
"description": "If you set an amount here, then the USD equivalent setting will not get applied.",
"fieldname": "amount_usd",
"fieldtype": "Currency",
@@ -238,12 +218,7 @@
{
"fieldname": "pricing_tab",
"fieldtype": "Tab Break",
"label": "Pricing"
},
{
"fieldname": "certification_tab",
"fieldtype": "Tab Break",
"label": "Certification"
"label": "Pricing and Certification"
},
{
"fieldname": "column_break_htgn",
@@ -284,6 +259,19 @@
"fieldtype": "Data",
"label": "Rating",
"read_only": 1
},
{
"default": "0",
"fieldname": "paid_certificate",
"fieldtype": "Check",
"label": "Paid Certificate"
},
{
"depends_on": "paid_certificate",
"fieldname": "evaluator",
"fieldtype": "Link",
"label": "Evaluator",
"options": "Course Evaluator"
}
],
"is_published_field": "published",
@@ -310,7 +298,7 @@
}
],
"make_attachments_public": 1,
"modified": "2024-10-30 23:08:31.842860",
"modified": "2025-02-24 11:50:58.325804",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Course",

View File

@@ -5,9 +5,8 @@ import json
import random
import frappe
from frappe.model.document import Document
from frappe.utils import cint, today
from frappe.utils.telemetry import capture
from lms.lms.utils import get_chapters, can_create_courses
from frappe.utils import today, cint
from lms.lms.utils import get_chapters
from ...utils import generate_slug, validate_image, update_payment_record
from frappe import _
@@ -19,6 +18,7 @@ class LMSCourse(Document):
self.validate_video_link()
self.validate_status()
self.validate_payments_app()
self.validate_certification()
self.validate_amount_and_currency()
self.image = validate_image(self.image)
@@ -52,10 +52,22 @@ class LMSCourse(Document):
if "payments" not in installed_apps:
frappe.throw(_("Please install the Payments app to create a paid courses."))
def validate_certification(self):
if self.enable_certification and self.paid_certificate:
frappe.throw(
_("A course cannot have both paid certificate and certificate of completion.")
)
if self.paid_certificate and not self.evaluator:
frappe.throw(_("Evaluator is required for paid certificates."))
def validate_amount_and_currency(self):
if self.paid_course and (not self.course_price and not self.currency):
if self.paid_course and (cint(self.course_price) < 0 or not self.currency):
frappe.throw(_("Amount and currency are required for paid courses."))
if self.paid_certificate and (cint(self.course_price) <= 0 or not self.currency):
frappe.throw(_("Amount and currency are required for paid certificates."))
def on_update(self):
if not self.upcoming and self.has_value_changed("upcoming"):
self.send_email_to_interested_users()

View File

@@ -14,6 +14,9 @@
"member",
"member_name",
"member_username",
"certification_section",
"purchased_certificate",
"certificate",
"section_break_8",
"cohort",
"subgroup",
@@ -123,11 +126,28 @@
"fieldtype": "Link",
"label": "Payment",
"options": "LMS Payment"
},
{
"fieldname": "certification_section",
"fieldtype": "Section Break",
"label": "Certification"
},
{
"default": "0",
"fieldname": "purchased_certificate",
"fieldtype": "Check",
"label": "Purchased Certificate"
},
{
"fieldname": "certificate",
"fieldtype": "Link",
"label": "Certificate",
"options": "LMS Certificate"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-10-30 12:44:16.103598",
"modified": "2025-02-21 17:11:37.986157",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Enrollment",

View File

@@ -14,6 +14,7 @@
"payment_for_document_type",
"payment_for_document",
"payment_received",
"payment_for_certificate",
"payment_details_section",
"currency",
"amount",
@@ -136,6 +137,12 @@
"fieldtype": "Link",
"label": "Source",
"options": "LMS Source"
},
{
"default": "0",
"fieldname": "payment_for_certificate",
"fieldtype": "Check",
"label": "Payment for Certificate"
}
],
"index_web_pages_for_search": 1,
@@ -149,7 +156,7 @@
"link_fieldname": "payment"
}
],
"modified": "2025-02-18 15:54:25.383353",
"modified": "2025-02-21 18:29:55.436611",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Payment",

View File

@@ -18,19 +18,26 @@ def validate_currency(payment_gateway, currency):
@frappe.whitelist()
def get_payment_link(doctype, docname, title, amount, total_amount, currency, address):
def get_payment_link(
doctype,
docname,
title,
amount,
total_amount,
currency,
address,
redirect_to,
payment_for_certificate,
):
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)
payment = record_payment(
address, doctype, docname, amount, currency, amount_with_gst, payment_for_certificate
)
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}",
@@ -53,7 +60,15 @@ def get_payment_link(doctype, docname, title, amount, total_amount, currency, ad
return url
def record_payment(address, doctype, docname, amount, currency, amount_with_gst=0):
def record_payment(
address,
doctype,
docname,
amount,
currency,
amount_with_gst=0,
payment_for_certificate=0,
):
address = frappe._dict(address)
address_name = save_address(address)
@@ -71,6 +86,7 @@ def record_payment(address, doctype, docname, amount, currency, amount_with_gst=
"source": address.source,
"payment_for_document_type": doctype,
"payment_for_document": docname,
"payment_for_certificate": payment_for_certificate,
}
)
payment_doc.save(ignore_permissions=True)

View File

@@ -68,27 +68,26 @@ def generate_slug(title, doctype):
return slugify(title, used_slugs=slugs)
def get_membership(course, member=None, batch=None):
def get_membership(course, member=None):
if not member:
member = frappe.session.user
filters = {"member": member, "course": course}
if batch:
filters["batch_old"] = batch
is_member = frappe.db.exists("LMS Enrollment", filters)
if is_member:
if frappe.db.exists("LMS Enrollment", filters):
membership = frappe.db.get_value(
"LMS Enrollment",
filters,
["name", "batch_old", "current_lesson", "member_type", "progress", "member"],
[
"name",
"current_lesson",
"progress",
"member",
"purchased_certificate",
"certificate",
],
as_dict=True,
)
if membership and membership.batch_old:
membership.batch_title = frappe.db.get_value(
"LMS Batch Old", membership.batch_old, "title"
)
return membership
return False
@@ -856,26 +855,44 @@ def is_onboarding_complete():
}
def get_evaluator(course, batch):
def get_evaluator(course, batch=None):
evaluator = None
evaluator = frappe.db.get_value(
"Batch Course",
{"parent": batch, "course": course},
"evaluator",
)
if batch:
evaluator = frappe.db.get_value(
"Batch Course",
{"parent": batch, "course": course},
"evaluator",
)
else:
evaluator = frappe.db.get_value("LMS Course", course, "evaluator")
return evaluator
@frappe.whitelist()
def get_upcoming_evals(student, courses):
def get_upcoming_evals(student, courses, batch=None):
filters = {
"member": student,
"course": ["in", courses],
"date": [">=", frappe.utils.nowdate()],
"status": "Upcoming",
}
if batch:
filters["batch_name"] = batch
upcoming_evals = frappe.get_all(
"LMS Certificate Request",
{
"member": student,
"course": ["in", courses],
"date": [">=", frappe.utils.nowdate()],
},
["date", "start_time", "course", "evaluator", "google_meet_link"],
filters,
[
"name",
"date",
"start_time",
"course",
"evaluator",
"google_meet_link",
"member",
"member_name",
],
order_by="date",
)
@@ -999,6 +1016,7 @@ def get_course_details(course):
"category",
"status",
"paid_course",
"paid_certificate",
"course_price",
"currency",
"amount_usd",
@@ -1013,7 +1031,7 @@ def get_course_details(course):
course_details.instructors = get_instructors(course_details.name)
# course_details.is_instructor = is_instructor(course_details.name)
if course_details.paid_course:
if course_details.paid_course or course_details.paid_certificate:
"""course_details.course_price, course_details.currency = check_multicurrency(
course_details.course_price, course_details.currency, None, course_details.amount_usd
)"""
@@ -1126,14 +1144,21 @@ def get_lesson(course, chapter, lesson):
return {}
membership = get_membership(course)
course_title = frappe.db.get_value("LMS Course", course, "title")
course_info = frappe.db.get_value(
"LMS Course", course, ["title", "paid_certificate"], as_dict=1
)
if (
not lesson_details.include_in_preview
and not membership
and not has_course_moderator_role()
and not is_instructor(course)
):
return {"no_preview": 1, "title": lesson_details.title, "course_title": course_title}
return {
"no_preview": 1,
"title": lesson_details.title,
"course_title": course_info.title,
}
lesson_details = frappe.db.get_value(
"Course Lesson",
@@ -1168,7 +1193,8 @@ def get_lesson(course, chapter, lesson):
lesson_details.prev = neighbours["prev"]
lesson_details.membership = membership
lesson_details.instructors = get_instructors(course)
lesson_details.course_title = course_title
lesson_details.course_title = course_info.title
lesson_details.paid_certificate = course_info.paid_certificate
return lesson_details
@@ -1233,6 +1259,7 @@ def get_batch_details(batch):
)
batch_details.instructors = get_instructors(batch)
batch_details.accept_enrollments = batch_details.start_date > getdate()
batch_details.courses = frappe.get_all(
"Batch Course", filters={"parent": batch}, fields=["course", "title", "evaluator"]
@@ -1601,11 +1628,19 @@ def get_order_summary(doctype, docname, country=None):
details = frappe.db.get_value(
"LMS Course",
docname,
["title", "name", "paid_course", "course_price as amount", "currency", "amount_usd"],
[
"title",
"name",
"paid_course",
"paid_certificate",
"course_price as amount",
"currency",
"amount_usd",
],
as_dict=True,
)
if not details.paid_course:
if not details.paid_course and not details.paid_certificate:
raise frappe.throw(_("This course is free."))
else:
@@ -1719,9 +1754,14 @@ def update_payment_record(doctype, docname):
"order_id": data.get("order_id"),
},
)
payment_for_certificate = frappe.db.get_value(
"LMS Payment", data.payment, "payment_for_certificate"
)
try:
if doctype == "LMS Course":
if payment_for_certificate:
update_certificate_purchase(docname)
elif doctype == "LMS Course":
enroll_in_course(data.payment, docname)
else:
enroll_in_batch(docname, data.payment)
@@ -1781,6 +1821,15 @@ def enroll_in_batch(batch, payment_name=None):
new_student.save()
def update_certificate_purchase(course):
frappe.db.set_value(
"LMS Enrollment",
{"member": frappe.session.user, "course": course},
"purchased_certificate",
1,
)
@frappe.whitelist()
def get_programs():
if (

View File

@@ -1,110 +0,0 @@
{% set chapters = get_chapters(course.name) %}
{% set is_instructor = is_instructor(course.name) %}
{% if chapters | length %}
<div class="course-home-outline">
{% if not lesson_page %}
<div class="page-title mb-8" id="outline-heading" data-course="{{ course.name }}">
{{ _("Course Content") }}
</div>
<!-- <div class="mb-2">
<span>
{{ chapters | length }} chapters
</span>
<span>
. {{ get_lessons(course.name, None, False) }} lessons
</span>
</div> -->
{% endif %}
{% if chapters | length %}
<div>
{% for chapter in chapters %}
{% set lessons = get_lessons(course.name, chapter) %}
<div class="chapter-parent" data-chapter="{{ chapter.name }}">
<div class="chapter-title" data-toggle="collapse" aria-expanded="false"
data-target="#{{ get_slugified_chapter_title(chapter.title) }}">
<img class="chapter-icon" src="/assets/lms/icons/chevron-right.svg">
<div class="chapter-title-main">
{{ chapter.title }}
</div>
<!-- <div class="small ml-auto">
{{ lessons | length }} lessons
</div> -->
</div>
<div class="chapter-content collapse navbar-collapse" id="{{ get_slugified_chapter_title(chapter.title) }}">
{% if chapter.description %}
<div class="chapter-description">
{{ chapter.description }}
</div>
{% endif %}
<div class="lessons">
{% if lessons | length %}
{% for lesson in lessons %}
{% set active = membership.current_lesson == lesson.name %}
<div data-lesson="{{ lesson.name }}" class="lesson-info {% if active %} active-lesson {% endif %}">
{% if membership or lesson.include_in_preview or is_instructor or has_course_moderator_role() %}
<a class="lesson-links"
href="{{ get_lesson_url(course.name, lesson.number) }}{% if classname %}?class={{ classname }}{% endif %}{{course.query_parameter}}"
{% if is_instructor and not lesson.include_in_preview %}
title="{{ _('This lesson is not available for preview. As you are the Instructor of the course only you can see it.') }}"
{% endif %}>
<svg class="icon icon-sm mr-2">
<use class="" href="#{{ lesson.icon }}">
</svg>
<span>{{ lesson.title }}</span>
{% if membership %}
<svg class="icon icon-md lesson-progress-tick ml-auto {{ get_progress(course.name, lesson.name) != 'Complete' and 'hide' }}">
<use class="" href="#icon-success">
</svg>
{% endif %}
</a>
{% else %}
<div class="no-preview" title="This lesson is not available for preview">
<div class="lesson-links">
<svg class="icon icon-sm mr-2">
<use class="" href="#icon-lock-gray">
</svg>
<div>{{ lesson.title }}</div>
</div>
</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% if chapters | length %}
<!-- No Preview Modal -->
{{ widgets.NoPreviewModal(course=course, membership=membership) }}
{% endif %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -100,4 +100,5 @@ lms.patches.v2_0.convert_quiz_duration_to_minutes
lms.patches.v2_0.allow_guest_access #05-02-2025
lms.patches.v2_0.migrate_batch_student_data #10-02-2025
lms.patches.v2_0.delete_old_enrollment_doctypes
lms.patches.v2_0.delete_unused_custom_fields
lms.patches.v2_0.delete_unused_custom_fields
lms.patches.v2_0.update_certificate_request_status

View File

@@ -0,0 +1,14 @@
import frappe
from frappe.utils import getdate
def execute():
evaluations = frappe.get_all("LMS Certificate Request", fields=["name", "date"])
for evaluation in evaluations:
if evaluation.date > getdate():
frappe.db.set_value("LMS Certificate Request", evaluation.name, "status", "Upcoming")
else:
frappe.db.set_value(
"LMS Certificate Request", evaluation.name, "status", "Completed"
)

3071
yarn.lock

File diff suppressed because it is too large Load Diff