Compare commits

..

85 Commits

Author SHA1 Message Date
Frappe PR Bot
795d95b482 chore(release): Bumped to Version 2.26.0 2025-03-05 13:04:57 +00:00
Jannat Patel
5b5b95c85c Merge pull request #1363 from pateljannat/scorm-issue-js-files
fix: scorm files getting wrong path
2025-03-05 17:03:40 +05:30
Jannat Patel
8490b07c90 fix: scorm files getting wrong path 2025-03-05 16:31:14 +05:30
Jannat Patel
dee2c51c60 Merge pull request #1359 from pateljannat/evaluation-validation-issue
fix: allow scheduling evals if future eval has been cancelled
2025-03-04 17:47:42 +05:30
Jannat Patel
4149fa6ce4 fix: renamed evaluation and certification buttons 2025-03-04 17:38:43 +05:30
Jannat Patel
7a69611f09 Merge pull request #1358 from pateljannat/payment-reminder-issue
fix: don't send payment reminder if member has already paid later
2025-03-04 17:34:30 +05:30
Jannat Patel
6692252df9 fix: allow scheduling evals if furture eval has been calcelled 2025-03-04 17:33:39 +05:30
Jannat Patel
486ce1bdb0 Merge pull request #1357 from pateljannat/course-certification-filter
refactor: course list fetching and filters
2025-03-04 17:27:01 +05:30
Jannat Patel
cceff77bc2 fix: don't send payment reminder if member has already paid later 2025-03-04 17:24:03 +05:30
Jannat Patel
22a9169f87 fix: show progress bar for enrolled courses 2025-03-04 17:14:01 +05:30
Jannat Patel
47a30763a0 refactor: course list fetching and filters 2025-03-04 17:02:47 +05:30
Jannat Patel
73379a1bd8 Merge pull request #1354 from pateljannat/dont-override-user
fix: reverting user doctype override
2025-03-04 13:08:12 +05:30
Jannat Patel
7cc46629b4 test: increased login request timeout in ui tests 2025-03-04 12:53:33 +05:30
Jannat Patel
67304245ba test: increased login request timeout in ui tests 2025-03-04 12:39:22 +05:30
Jannat Patel
8edd3a1a34 chore: upgrading actions/cache to v4 for ui tests 2025-03-04 11:43:07 +05:30
Jannat Patel
e4bc7c8d78 Merge pull request #1356 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-03-04 10:22:56 +05:30
Jannat Patel
a8af78d400 chore: Esperanto translations 2025-03-04 02:59:49 +05:30
Jannat Patel
0afe3de818 chore: Bosnian translations 2025-03-04 02:59:48 +05:30
Jannat Patel
3c81aadec6 chore: Persian translations 2025-03-04 02:59:47 +05:30
Jannat Patel
1dfcb035da chore: Chinese Simplified translations 2025-03-04 02:59:45 +05:30
Jannat Patel
77b24882a9 chore: Turkish translations 2025-03-04 02:59:44 +05:30
Jannat Patel
1fd0673257 chore: Swedish translations 2025-03-04 02:59:42 +05:30
Jannat Patel
dbda76e0ce chore: Russian translations 2025-03-04 02:59:40 +05:30
Jannat Patel
a9d22521ce chore: Polish translations 2025-03-04 02:59:39 +05:30
Jannat Patel
6da1d9629f chore: Hungarian translations 2025-03-04 02:59:37 +05:30
Jannat Patel
37b61a7087 chore: German translations 2025-03-04 02:59:35 +05:30
Jannat Patel
9b484e6ee9 chore: Arabic translations 2025-03-04 02:59:34 +05:30
Jannat Patel
5ef67ef21c chore: Spanish translations 2025-03-04 02:59:32 +05:30
Jannat Patel
f902166643 chore: French translations 2025-03-04 02:59:31 +05:30
Md Hussain Nagaria
8f91466b3d Merge pull request #1355 from frappe/enhance-timezone
feat: autofill timezone based on user timezone
2025-03-03 22:43:24 +05:30
Hussain Nagaria
fa1621c3d1 feat: autofill client timezone based on user timezone 2025-03-03 22:42:43 +05:30
Jannat Patel
2acd45feae fix: reverting user doctype override 2025-03-03 19:54:41 +05:30
Jannat Patel
f19e974b9d Merge pull request #1353 from pateljannat/mark-eval-request-complete
feat: mark evaluation requests as complete
2025-03-03 17:14:42 +05:30
Jannat Patel
01598ac002 feat: mark evaluation requests as complete 2025-03-03 17:01:10 +05:30
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
85 changed files with 10219 additions and 7601 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

@@ -58,7 +58,7 @@ jobs:
echo "127.0.0.1 lms.test" | sudo tee -a /etc/hosts
- name: Cache pip
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}

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

@@ -37,6 +37,7 @@ Cypress.Commands.add("login", (email, password) => {
url: "/api/method/login",
method: "POST",
body: { usr: email, pwd: password },
timeout: 60000,
});
});

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

@@ -16,7 +16,8 @@
{{ __('Featured') }}
</Badge>
<div
v-for="tag in course.tags"
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md"
>
{{ tag }}
@@ -100,9 +101,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'
import { reactive, inject, onMounted } from 'vue'
import { getTimezones, createToast, getUserTimezone } from '@/utils/'
const liveClasses = defineModel('reloadLiveClasses')
const show = defineModel()
@@ -120,6 +122,10 @@ let liveClass = reactive({
host: user.data.name,
})
onMounted(() => {
liveClass.timezone = getUserTimezone()
})
const getTimezoneOptions = () => {
return getTimezones().map((timezone) => {
return {

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

@@ -1,316 +1,325 @@
<template>
<div v-if="courses.data">
<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"
<header
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<router-link
v-if="user.data?.is_moderator"
:to="{
name: 'CourseForm',
params: { courseName: 'new' },
}"
>
<Breadcrumbs
class="h-7"
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
/>
<div class="flex space-x-2 justify-end">
<div class="w-40 md:w-44">
<FormControl
v-if="categories.data?.length"
type="select"
v-model="currentCategory"
:options="categories.data"
:placeholder="__('Category')"
/>
</div>
<div class="w-28 md:w-36">
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</router-link>
</header>
<div class="p-5 pb-10">
<div
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
>
<div class="text-lg font-semibold">
{{ __('All Courses') }}
</div>
<div
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
>
<TabButtons
v-if="user.data"
:buttons="courseTabs"
v-model="currentTab"
/>
<FormControl
v-model="certification"
:label="__('Certification')"
type="checkbox"
@change="updateCourses()"
/>
<div class="grid grid-cols-2 gap-2">
<FormControl
v-model="title"
:placeholder="__('Search by Title')"
type="text"
placeholder="Search"
v-model="searchQuery"
@input="courses.reload()"
>
<template #prefix>
<Search
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
name="search"
/>
</template>
</FormControl>
</div>
<router-link
v-if="user.data?.is_moderator || user.data?.is_instructor"
:to="{
name: 'CourseForm',
params: {
courseName: 'new',
},
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('New') }}
</Button>
</router-link>
</div>
</header>
<div class="">
<Tabs
v-if="hasCourses"
as="div"
v-model="tabIndex"
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
:tabs="makeTabs"
>
<template #tab="{ tab, selected }">
<div>
<button
class="group -mb-px flex items-center gap-2 overflow-hidden border-b border-transparent py-2.5 text-base text-ink-gray-5 duration-300 ease-in-out hover:border-outline-gray-3 hover:text-ink-gray-9"
:class="{ 'text-ink-gray-9': selected }"
>
<component v-if="tab.icon" :is="tab.icon" class="h-5" />
{{ __(tab.label) }}
<Badge theme="gray">
{{ tab.count }}
</Badge>
</button>
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
@input="updateCourses()"
/>
<div class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40">
<Select
v-if="categories.length"
v-model="currentCategory"
:options="categories"
:placeholder="__('Category')"
@change="updateCourses()"
/>
</div>
</template>
<template #tab-panel="{ tab }">
<div
v-if="tab.courses && tab.courses.value.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 gap-7 my-5 mx-5"
>
<router-link
v-for="course in tab.courses.value"
:to="
course.membership && course.current_lesson
? {
name: 'Lesson',
params: {
courseName: course.name,
chapterNumber: course.current_lesson.split('-')[0],
lessonNumber: course.current_lesson.split('-')[1],
},
}
: course.membership
? {
name: 'Lesson',
params: {
courseName: course.name,
chapterNumber: 1,
lessonNumber: 1,
},
}
: {
name: 'CourseDetail',
params: { courseName: course.name },
}
"
>
<CourseCard :course="course" />
</router-link>
</div>
<div v-else class="p-5 italic text-ink-gray-4">
{{ __('No {0} courses').format(tab.label.toLowerCase()) }}
</div>
</template>
</Tabs>
<div
v-else-if="
!courses.loading &&
(user.data?.is_moderator || user.data?.is_instructor)
"
class="grid grid-cols-3 p-5"
>
<router-link
:to="{
name: 'CourseForm',
params: {
courseName: 'new',
},
}"
>
<div class="bg-surface-menu-bar py-32 px-5 rounded-md">
<div class="flex flex-col items-center text-center space-y-2">
<Plus
class="size-10 stroke-1 text-ink-gray-8 p-1 rounded-full border bg-surface-white"
/>
<div class="font-medium">
{{ __('Create a Course') }}
</div>
<span class="text-ink-gray-7 text-sm leading-4">
{{ __('You can add chapters and lessons to it.') }}
</span>
</div>
</div>
</router-link>
</div>
<div
v-else-if="!courses.loading && !hasCourses"
class="text-center p-5 text-ink-gray-5 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-xl font-medium">
{{ __('No courses found') }}
</div>
<div class="leading-5">
{{
__(
'There are no courses available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
</div>
<div
v-if="courses.data?.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-5"
>
<router-link
v-for="course in courses.data"
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
>
<CourseCard :course="course" />
</router-link>
</div>
<div
v-else-if="!courses.list.loading"
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 italic mt-48"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1">
{{ __('No courses found') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{
__(
'There are no courses matching the criteria. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
<div
v-if="!courses.list.loading && courses.hasNextPage"
class="flex justify-center mt-5"
>
<Button @click="courses.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</template>
<script setup>
import {
Badge,
Breadcrumbs,
Button,
call,
createResource,
createListResource,
FormControl,
Tabs,
Select,
TabButtons,
} from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue'
import { BookOpen, Plus, Search } from 'lucide-vue-next'
import { ref, computed, inject, onMounted, watch } from 'vue'
import { computed, inject, onMounted, ref, watch } from 'vue'
import { BookOpen, Plus } from 'lucide-vue-next'
import { updateDocumentTitle } from '@/utils'
import { useRouter } from 'vue-router'
import { useSettings } from '@/stores/settings'
import CourseCard from '@/components/CourseCard.vue'
const user = inject('$user')
const searchQuery = ref('')
const dayjs = inject('$dayjs')
const start = ref(0)
const pageLength = ref(30)
const categories = ref([])
const currentCategory = ref(null)
const hasCourses = ref(false)
const router = useRouter()
const settings = useSettings()
const title = ref('')
const certification = ref(false)
const filters = ref({})
const currentTab = ref('Live')
onMounted(() => {
checkLearningPath()
let queries = new URLSearchParams(location.search)
if (queries.has('category')) {
currentCategory.value = queries.get('category')
}
setFiltersFromQuery()
updateCourses()
categories.value = [
{
label: '',
value: null,
},
]
})
const checkLearningPath = () => {
if (
settings.learningPaths.data &&
(!user.data?.is_moderator || !user.data?.is_instructor)
) {
router.push({ name: 'Programs' })
const setFiltersFromQuery = () => {
let queries = new URLSearchParams(location.search)
title.value = queries.get('title') || ''
currentCategory.value = queries.get('category') || null
certification.value = queries.get('certification') || false
}
const courses = createListResource({
doctype: 'LMS Course',
url: 'lms.lms.utils.get_courses',
cache: ['courses', user.data?.name],
pageLength: pageLength.value,
start: start.value,
onSuccess(data) {
let allCategories = data.map((course) => course.category)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
},
})
const updateCourses = () => {
updateFilters()
courses.update({
filters: filters.value,
})
courses.reload()
}
const updateFilters = () => {
updateCategoryFilter()
updateTitleFilter()
updateCertificationFilter()
updateTabFilter()
updateStudentFilter()
setQueryParams()
}
const updateCategoryFilter = () => {
if (currentCategory.value) {
filters.value['category'] = currentCategory.value
} else {
delete filters.value['category']
}
}
const courses = createResource({
url: 'lms.lms.utils.get_courses',
cache: ['courses', user.data?.email],
auto: true,
const updateTitleFilter = () => {
if (title.value) {
filters.value['title'] = ['like', `%${title.value}%`]
} else {
delete filters.value['title']
}
}
const updateCertificationFilter = () => {
if (certification.value) {
filters.value['certification'] = 1
} else {
delete filters.value['certification']
}
}
const updateTabFilter = () => {
if (!user.data) {
return
}
delete filters.value['live']
delete filters.value['created']
delete filters.value['published_on']
delete filters.value['upcoming']
if (currentTab.value == 'Enrolled' && user.data?.is_student) {
filters.value['enrolled'] = 1
delete filters.value['published']
} else {
delete filters.value['published']
delete filters.value['enrolled']
if (currentTab.value == 'Live') {
filters.value['published'] = 1
filters.value['upcoming'] = 0
filters.value['live'] = 1
} else if (currentTab.value == 'Upcoming') {
filters.value['upcoming'] = 1
filters.value['published'] = 1
} else if (currentTab.value == 'New') {
filters.value['published'] = 1
filters.value['published_on'] = [
'>=',
dayjs().add(-3, 'month').format('YYYY-MM-DD'),
]
} else if (currentTab.value == 'Created') {
filters.value['created'] = 1
}
}
}
const updateStudentFilter = () => {
if (!user.data || (user.data?.is_student && currentTab.value != 'Enrolled')) {
filters.value['published'] = 1
}
}
const setQueryParams = () => {
let queries = new URLSearchParams(location.search)
let filterKeys = {
title: title.value,
category: currentCategory.value,
certification: certification.value,
}
Object.keys(filterKeys).forEach((key) => {
if (filterKeys[key]) {
queries.set(key, filterKeys[key])
} else {
queries.delete(key)
}
})
history.replaceState({}, '', `${location.pathname}?${queries.toString()}`)
}
const updateCategories = (data) => {
data.forEach((course) => {
if (
course.category &&
!categories.value.find((category) => category.value === course.category)
)
categories.value.push({
label: course.category,
value: course.category,
})
})
}
watch(currentTab, () => {
updateCourses()
})
const tabIndex = ref(0)
let tabs
const courseType = computed(() => {
let types = [
{ label: __(''), value: null },
{ label: __('New'), value: 'New' },
{ label: __('Upcoming'), value: 'Upcoming' },
]
if (user.data?.is_student) {
types.push({ label: __('Enrolled'), value: 'Enrolled' })
} else {
types.push({ label: __('Created'), value: 'Created' })
}
return types
})
const makeTabs = computed(() => {
tabs = []
addToTabs('Live')
addToTabs('New')
addToTabs('Upcoming')
if (user.data) {
addToTabs('Enrolled')
if (
user.data.is_moderator ||
user.data.is_instructor ||
courses.data?.created?.length
) {
addToTabs('Created')
}
if (user.data.is_moderator) {
addToTabs('Under Review')
}
const courseTabs = computed(() => {
let tabs = [
{
label: __('Live'),
},
{
label: __('New'),
},
{
label: __('Upcoming'),
},
]
if (user.data?.is_student) {
tabs.push({ label: __('Enrolled') })
} else {
tabs.push({ label: __('Created') })
}
return tabs
})
const addToTabs = (label) => {
let courses = getCourses(label.toLowerCase().split(' ').join('_'))
tabs.push({
label,
courses: computed(() => courses),
count: computed(() => courses.length),
})
}
const getCourses = (type) => {
let courseList = courses.data[type]
if (searchQuery.value) {
let query = searchQuery.value.toLowerCase()
courseList = courseList.filter(
(course) =>
course.title.toLowerCase().includes(query) ||
course.short_introduction.toLowerCase().includes(query) ||
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
)
}
if (currentCategory.value && currentCategory.value != '') {
courseList = courseList.filter(
(course) => course.category == currentCategory.value
)
}
return courseList
}
const categories = createResource({
url: 'lms.lms.api.get_categories',
makeParams() {
return {
doctype: 'LMS Course',
filters: {
published: 1,
},
}
const breadcrumbs = computed(() => [
{
label: __('Courses'),
route: { name: 'Courses' },
},
cache: ['courseCategories'],
auto: true,
transform(data) {
data.unshift({
label: '',
value: null,
})
},
})
watch(courses, () => {
if (courses.data) {
Object.keys(courses.data).forEach((section) => {
if (courses.data[section].length) {
hasCourses.value = true
}
})
}
})
watch(
() => currentCategory.value,
() => {
let queries = new URLSearchParams(location.search)
if (currentCategory.value) {
queries.set('category', currentCategory.value)
} else {
queries.delete('category')
}
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
}
)
])
const pageMeta = computed(() => {
return {
title: 'Courses',
description: 'All Courses divided by categories',
description: 'All published courses.',
}
})

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

@@ -72,7 +72,7 @@ const roles = createResource({
})
const updateRole = createResource({
url: 'lms.overrides.user.save_role',
url: 'lms.lms.api.save_role',
makeParams(values) {
return {
user: props.profile.data?.name,

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

@@ -441,6 +441,22 @@ export function getTimezones() {
]
}
export function getUserTimezone() {
try {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const supportedTimezones = getTimezones()
if (supportedTimezones.includes(timezone)) {
return timezone // e.g., 'Asia/Calcutta', 'America/New_York', etc.
} else {
throw Error('unsupported timezone')
}
} catch (error) {
console.error('Error getting timezone:', error)
return null
}
}
export function getSidebarLinks() {
return [
{

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

View File

@@ -12,7 +12,7 @@ module.exports = {
1.5: '1.5',
},
screens: {
'2xl': '1536px',
'2xl': '1600px',
'3xl': '1920px',
},
},

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.26.0"

View File

@@ -88,7 +88,6 @@ setup_wizard_requires = "assets/lms/js/setup_wizard.js"
# Override standard doctype classes
override_doctype_class = {
"User": "lms.overrides.user.CustomUser",
"Web Template": "lms.overrides.web_template.CustomWebTemplate",
}
@@ -104,6 +103,10 @@ doc_events = {
},
"Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"},
"Notification Log": {"on_change": "lms.lms.utils.publish_notifications"},
"User": {
"validate": "lms.lms.user.validate_username_duplicates",
"after_insert": "lms.lms.user.after_insert",
},
}
# Scheduled Tasks
@@ -112,6 +115,7 @@ scheduler_events = {
"hourly": [
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals",
"lms.lms.api.update_course_statistics",
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.mark_eval_as_completed",
],
"daily": [
"lms.job.doctype.job_opportunity.job_opportunity.update_job_openings",
@@ -190,8 +194,8 @@ jinja = {
"lms.lms.utils.get_lesson_index",
"lms.lms.utils.get_lesson_url",
"lms.page_renderers.get_profile_url",
"lms.overrides.user.get_palette",
"lms.lms.utils.is_instructor",
"lms.lms.utils.get_palette",
],
"filters": [],
}
@@ -238,8 +242,6 @@ profile_url_prefix = "/users/"
signup_form_template = "lms.plugins.show_custom_signup"
on_session_creation = "lms.overrides.user.on_session_creation"
add_to_apps_screen = [
{
"name": "lms",

View File

@@ -22,7 +22,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 +179,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 +196,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 +221,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 +229,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 +389,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 +1248,76 @@ 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}
@frappe.whitelist()
def save_role(user, role, value):
frappe.only_for("Moderator")
if cint(value):
doc = frappe.get_doc(
{
"doctype": "Has Role",
"parent": user,
"role": role,
"parenttype": "User",
"parentfield": "roles",
}
)
doc.save(ignore_permissions=True)
else:
frappe.db.delete("Has Role", {"parent": user, "role": role})
return True

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

@@ -4,7 +4,7 @@
frappe.ui.form.on("LMS Certificate Evaluation", {
refresh: function (frm) {
if (!frm.is_new() && frm.doc.status == "Pass") {
frm.add_custom_button(__("Create LMS Certificate"), () => {
frm.add_custom_button(__("Create Certificate"), () => {
frappe.model.open_mapped_doc({
method: "lms.lms.doctype.lms_certificate_evaluation.lms_certificate_evaluation.create_lms_certificate",
frm: frm,

View File

@@ -3,18 +3,15 @@
frappe.ui.form.on("LMS Certificate Request", {
refresh: function (frm) {
if (!frm.is_new()) {
frm.add_custom_button(
__("Create LMS Certificate Evaluation"),
() => {
frappe.model.open_mapped_doc({
method: "lms.lms.doctype.lms_certificate_request.lms_certificate_request.create_lms_certificate_evaluation",
frm: frm,
});
}
);
if (!frm.is_new() && frm.doc.status == "Upcoming") {
frm.add_custom_button(__("Conduct Evaluation"), () => {
frappe.model.open_mapped_doc({
method: "lms.lms.doctype.lms_certificate_request.lms_certificate_request.create_lms_certificate_evaluation",
frm: frm,
});
});
}
if (!frm.doc.google_meet_link) {
if (!frm.doc.google_meet_link && frm.doc.status == "Upcoming") {
frm.add_custom_button(__("Generate Google Meet Link"), () => {
frappe.call({
method: "lms.lms.doctype.lms_certificate_request.lms_certificate_request.setup_calendar_event",

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

@@ -77,6 +77,7 @@ class LMSCertificateRequest(Document):
"member": self.member,
"course": self.course,
"name": ["!=", self.name],
"status": "Upcoming",
},
["date", "start_time", "course"],
)
@@ -150,7 +151,11 @@ def schedule_evals():
timelapse = add_to_date(get_datetime(), hours=-5)
evals = frappe.get_all(
"LMS Certificate Request",
{"creation": [">=", timelapse], "google_meet_link": ["is", "not set"]},
{
"creation": [">=", timelapse],
"google_meet_link": ["is", "not set"],
"status": "Upcoming",
},
["name", "member", "member_name", "evaluator", "date", "start_time", "end_time"],
)
for eval in evals:
@@ -254,3 +259,20 @@ def create_lms_certificate_evaluation(source_name, target_doc=None):
target_doc,
)
return doc
def mark_eval_as_completed():
requests = frappe.get_all(
"LMS Certificate Request",
{
"status": "Upcoming",
"date": ["<=", getdate()],
},
["name", "end_time", "date"],
)
for req in requests:
if req.date < getdate():
frappe.db.set_value("LMS Certificate Request", req.name, "status", "Completed")
elif req.date == getdate() and get_time(req.end_time) < get_time(nowtime()):
frappe.db.set_value("LMS Certificate Request", req.name, "status", "Completed")

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",
@@ -267,14 +242,14 @@
{
"default": "0",
"fieldname": "enrollments",
"fieldtype": "Data",
"fieldtype": "Int",
"label": "Enrollments",
"read_only": 1
},
{
"default": "0",
"fieldname": "lessons",
"fieldtype": "Data",
"fieldtype": "Int",
"label": "Lessons",
"read_only": 1
},
@@ -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-03-04 15:43:25.151554",
"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

@@ -33,9 +33,24 @@ def send_payment_reminder():
)
for payment in incomplete_payments:
if has_paid_later(payment):
continue
send_mail(payment)
def has_paid_later(payment):
return frappe.db.exists(
"LMS Payment",
{
"member": payment.member,
"payment_received": 1,
"payment_for_document": payment.payment_for_document,
"payment_for_document_type": payment.payment_for_document_type,
},
)
def send_mail(payment):
subject = _("Complete Your Enrollment - Don't miss out!")
template = "payment_reminder"

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)

85
lms/lms/user.py Normal file
View File

@@ -0,0 +1,85 @@
import frappe
from frappe import _
from frappe.model.naming import append_number_if_name_exists
from frappe.website.utils import cleanup_page_name
from frappe.website.utils import is_signup_disabled
from frappe.utils import random_string, escape_html
from lms.lms.utils import get_country_code
def validate_username_duplicates(doc, method):
while not doc.username or doc.username_exists():
doc.username = append_number_if_name_exists(
doc.doctype, cleanup_page_name(doc.full_name), fieldname="username"
)
if " " in doc.username:
doc.username = doc.username.replace(" ", "")
if len(doc.username) < 4:
doc.username = doc.email.replace("@", "").replace(".", "")
def after_insert(doc, method):
doc.add_roles("LMS Student")
@frappe.whitelist(allow_guest=True)
def sign_up(email, full_name, verify_terms, user_category):
if is_signup_disabled():
frappe.throw(_("Sign Up is disabled"), _("Not Allowed"))
user = frappe.db.get("User", {"email": email})
if user:
if user.enabled:
return 0, _("Already Registered")
else:
return 0, _("Registered but disabled")
else:
if frappe.db.get_creation_count("User", 60) > 300:
frappe.respond_as_web_page(
_("Temporarily Disabled"),
_(
"Too many users signed up recently, so the registration is disabled. Please try back in an hour"
),
http_status_code=429,
)
user = frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": escape_html(full_name),
"verify_terms": verify_terms,
"user_category": user_category,
"country": "",
"enabled": 1,
"new_password": random_string(10),
"user_type": "Website User",
}
)
user.flags.ignore_permissions = True
user.flags.ignore_password_policy = True
user.insert()
# set default signup role as per Portal Settings
default_role = frappe.db.get_single_value("Portal Settings", "default_role")
if default_role:
user.add_roles(default_role)
user.add_roles("LMS Student")
set_country_from_ip(None, user.name)
if user.flags.email_sent:
return 1, _("Please check your email for verification")
else:
return 2, _("Please ask your administrator to verify your sign-up")
def set_country_from_ip(login_manager=None, user=None):
if not user and login_manager:
user = login_manager.user
user_country = frappe.db.get_value("User", user, "country")
# if user_country:
# return
frappe.db.set_value("User", user, "country", get_country_code())
return

View File

@@ -1,6 +1,7 @@
import re
import string
import frappe
import hashlib
import json
import razorpay
import requests
@@ -68,27 +69,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 +856,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",
)
@@ -967,17 +985,145 @@ def change_currency(amount, currency, country=None):
@frappe.whitelist(allow_guest=True)
def get_courses():
def get_courses(filters=None, start=0, page_length=20):
"""Returns the list of courses."""
courses = []
course_list = frappe.get_all("LMS Course", pluck="name")
for course in course_list:
courses.append(get_course_details(course))
courses = get_categorized_courses(courses)
if not filters:
filters = {}
filters, or_filters, show_featured = update_course_filters(filters)
fields = get_course_fields()
courses = frappe.get_all(
"LMS Course",
filters=filters,
fields=fields,
or_filters=or_filters,
order_by="enrollments desc",
start=start,
page_length=page_length,
)
if show_featured:
courses = get_featured_courses(filters, or_filters, fields) + courses
courses = get_enrollment_details(courses)
courses = get_course_card_details(courses)
return courses
def get_course_card_details(courses):
for course in courses:
course.instructors = get_instructors(course.name)
if course.paid_course and course.published == 1:
course.amount, course.currency = check_multicurrency(
course.course_price, course.currency, None, course.amount_usd
)
course.price = fmt_money(course.amount, 0, course.currency)
return courses
def get_course_or_filters(filters):
or_filters = {}
or_filters.update({"title": filters.get("title")})
or_filters.update({"short_introduction": filters.get("title")})
or_filters.update({"description": filters.get("title")})
or_filters.update({"tags": filters.get("title")})
return or_filters
def update_course_filters(filters):
or_filters = {}
show_featured = False
if filters.get("title"):
or_filters = get_course_or_filters(filters)
del filters["title"]
if filters.get("enrolled"):
enrolled_courses = frappe.get_all(
"LMS Enrollment", {"member": frappe.session.user}, pluck="course"
)
filters.update({"name": ["in", enrolled_courses]})
del filters["enrolled"]
if filters.get("created"):
created_courses = frappe.get_all(
"Course Instructor", {"instructor": frappe.session.user}, pluck="parent"
)
filters.update({"name": ["in", created_courses]})
del filters["created"]
if filters.get("live"):
filters.update({"featured": 0})
show_featured = True
del filters["live"]
if filters.get("certification"):
or_filters.update({"enable_certification": 1})
or_filters.update({"paid_certificate": 1})
del filters["certification"]
return filters, or_filters, show_featured
def get_enrollment_details(courses):
for course in courses:
filters = {
"course": course.name,
"member": frappe.session.user,
}
if frappe.db.exists("LMS Enrollment", filters):
course.membership = frappe.db.get_value(
"LMS Enrollment",
filters,
["name", "course", "current_lesson", "progress", "member"],
as_dict=1,
)
return courses
def get_featured_courses(filters, or_filters, fields):
filters.update({"featured": 1})
featured_courses = frappe.get_all(
"LMS Course",
filters=filters,
fields=fields,
or_filters=or_filters,
order_by="enrollments desc",
)
return featured_courses
def get_course_fields():
return [
"name",
"title",
"tags",
"image",
"short_introduction",
"published",
"upcoming",
"featured",
"disable_self_learning",
"published_on",
"category",
"status",
"paid_course",
"paid_certificate",
"course_price",
"currency",
"amount_usd",
"enable_certification",
"lessons",
"enrollments",
"rating",
]
@frappe.whitelist(allow_guest=True)
def get_course_details(course):
course_details = frappe.db.get_value(
@@ -999,6 +1145,7 @@ def get_course_details(course):
"category",
"status",
"paid_course",
"paid_certificate",
"course_price",
"currency",
"amount_usd",
@@ -1013,7 +1160,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 +1273,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 +1322,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 +1388,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 +1757,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 +1883,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 +1950,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 (
@@ -1960,3 +2138,25 @@ def get_batch_card_details(batches):
batch.price = fmt_money(batch.amount, 0, batch.currency)
return batches
def get_palette(full_name):
"""
Returns a color unique to each member for Avatar"""
palette = [
["--orange-avatar-bg", "--orange-avatar-color"],
["--pink-avatar-bg", "--pink-avatar-color"],
["--blue-avatar-bg", "--blue-avatar-color"],
["--green-avatar-bg", "--green-avatar-color"],
["--dark-green-avatar-bg", "--dark-green-avatar-color"],
["--red-avatar-bg", "--red-avatar-color"],
["--yellow-avatar-bg", "--yellow-avatar-color"],
["--purple-avatar-bg", "--purple-avatar-color"],
["--gray-avatar-bg", "--gray-avatar-color0"],
]
encoded_name = str(full_name).encode("utf-8")
hash_name = hashlib.md5(encoded_name).hexdigest()
idx = cint((int(hash_name[4:6], 16) + 1) / 5.33)
return palette[idx % 8]

View File

@@ -1,11 +0,0 @@
{% set member = frappe.get_doc("User", frappe.session.user) %}
<div class="mt-10">
{% if member.get_mentored_courses() | length %}
<div class="course-home-headings"> {{ _("Courses Mentored") }} </div>
<div class="cards-parent">
{% for course in member.get_mentored_courses() %}
{{ widgets.CourseCard(course=course) }}
{% endfor %}
</div>
{% endif %}
</div>

View File

@@ -1,16 +0,0 @@
{
"__unsaved": 1,
"creation": "2021-10-21 11:32:57.411626",
"docstatus": 0,
"doctype": "Web Template",
"fields": [],
"idx": 0,
"modified": "2021-10-21 12:01:56.270656",
"modified_by": "Administrator",
"module": "LMS",
"name": "Courses Mentored",
"owner": "Administrator",
"standard": 1,
"template": "",
"type": "Section"
}

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

View File

@@ -1,31 +0,0 @@
{% set color = get_palette(member.full_name) %}
<div class="common-card-style member-card">
<div class="d-flex">
{{ widgets.Avatar(member=member, avatar_class=avatar_class) }}
<div class="ml-3 my-auto">
<div class="member-card-title">
{{ member.full_name }}
</div>
{% if member.headline %}
<div> {{ member.headline }} </div>
{% endif %}
{% if member.looking_for_job %}
<div class="indicator-pill green"> {{ _("Open Network") }} </div>
{% endif %}
{% set course_count = get_authored_courses(member.name, True) | length %}
{% set suffix = "Courses" if course_count > 1 else "Course" %}
{% if show_course_count and course_count > 0 %}
<div class="">
Created {{ course_count }} {{ suffix }}
</div>
{% endif %}
</div>
<a class="stretched-link" href="{{ get_profile_url(member.username) }}"></a>
</div>
</div>

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

@@ -1,34 +0,0 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
import unittest
import frappe
from lms.lms.doctype.lms_course.test_lms_course import new_user
class TestCustomUser(unittest.TestCase):
def test_with_basic_username(self):
user = new_user("Username", "test_with_basic_username@example.com")
self.assertEqual(user.username, "username")
def test_without_username(self):
"""The user in this test has the same first name as the user of the test test_with_basic_username.
In such cases frappe makes the username of the second user empty.
The condition in lms app should override this and save a username."""
user = new_user("Username", "test-without-username@example.com")
self.assertTrue(user.username)
def test_with_short_first_name(self):
user = new_user("USN", "test_with_short_first_name@example.com")
self.assertGreaterEqual(len(user.username), 4)
@classmethod
def tearDownClass(cls) -> None:
users = [
"test_with_basic_username@example.com",
"test-without-username@example.com",
"test_with_short_first_name@example.com",
]
frappe.db.delete("User", {"name": ["in", users]})

View File

@@ -1,363 +0,0 @@
import hashlib
import frappe
import requests
from frappe import _
from frappe.core.doctype.user.user import User
from frappe.utils import cint, escape_html, random_string
from frappe.website.utils import is_signup_disabled
from lms.lms.utils import get_average_rating, get_country_code
from frappe.website.utils import cleanup_page_name
from frappe.model.naming import append_number_if_name_exists
from lms.widgets import Widgets
class CustomUser(User):
def validate(self):
super().validate()
self.validate_username_duplicates()
def after_insert(self):
super().after_insert()
self.add_roles("LMS Student")
def validate_username_duplicates(self):
while not self.username or self.username_exists():
self.username = append_number_if_name_exists(
self.doctype, cleanup_page_name(self.full_name), fieldname="username"
)
if " " in self.username:
self.username = self.username.replace(" ", "")
if len(self.username) < 4:
self.username = self.email.replace("@", "").replace(".", "")
def validate_skills(self):
unique_skills = []
for skill in self.skill:
if not skill.skill_name:
return
if not skill.skill_name in unique_skills:
unique_skills.append(skill.skill_name)
else:
frappe.throw(_("Skills must be unique"))
def get_batch_count(self) -> int:
"""Returns the number of batches authored by this user."""
return frappe.db.count(
"LMS Enrollment", {"member": self.name, "member_type": "Mentor"}
)
def get_user_reviews(self):
"""Returns the reviews created by user"""
return frappe.get_all("LMS Course Review", {"owner": self.name})
def get_mentored_courses(self):
"""Returns all courses mentored by this user"""
mentored_courses = []
mapping = frappe.get_all(
"LMS Course Mentor Mapping",
{
"mentor": self.name,
},
["name", "course"],
)
for map in mapping:
if frappe.db.get_value("LMS Course", map.course, "published"):
course = frappe.db.get_value(
"LMS Course",
map.course,
["name", "upcoming", "title", "image", "enable_certification"],
as_dict=True,
)
mentored_courses.append(course)
return mentored_courses
def get_enrolled_courses():
in_progress = []
completed = []
memberships = get_course_membership(None, member_type="Student")
for membership in memberships:
course = frappe.db.get_value(
"LMS Course",
membership.course,
[
"name",
"upcoming",
"title",
"short_introduction",
"image",
"enable_certification",
"paid_course",
"course_price",
"currency",
"published",
"creation",
],
as_dict=True,
)
if not course.published:
continue
course.enrollment_count = frappe.db.count(
"LMS Enrollment", {"course": course.name, "member_type": "Student"}
)
course.avg_rating = get_average_rating(course.name) or 0
progress = cint(membership.progress)
if progress < 100:
in_progress.append(course)
else:
completed.append(course)
in_progress.sort(key=lambda x: x.enrollment_count, reverse=True)
completed.sort(key=lambda x: x.enrollment_count, reverse=True)
return {"in_progress": in_progress, "completed": completed}
def get_course_membership(member=None, member_type=None):
"""Returns all memberships of the user."""
filters = {"member": member or frappe.session.user}
if member_type:
filters["member_type"] = member_type
return frappe.get_all("LMS Enrollment", filters, ["name", "course", "progress"])
def get_authored_courses(member=None, only_published=True):
"""Returns the number of courses authored by this user."""
course_details = []
courses = frappe.get_all(
"Course Instructor", {"instructor": member or frappe.session.user}, ["parent"]
)
for course in courses:
detail = frappe.db.get_value(
"LMS Course",
course.parent,
[
"name",
"upcoming",
"title",
"short_introduction",
"image",
"paid_course",
"course_price",
"currency",
"status",
"published",
"creation",
],
as_dict=True,
)
if only_published and detail and not detail.published:
continue
detail.enrollment_count = frappe.db.count(
"LMS Enrollment", {"course": detail.name, "member_type": "Student"}
)
detail.avg_rating = get_average_rating(detail.name) or 0
course_details.append(detail)
course_details.sort(key=lambda x: x.enrollment_count, reverse=True)
return course_details
def get_palette(full_name):
"""
Returns a color unique to each member for Avatar"""
palette = [
["--orange-avatar-bg", "--orange-avatar-color"],
["--pink-avatar-bg", "--pink-avatar-color"],
["--blue-avatar-bg", "--blue-avatar-color"],
["--green-avatar-bg", "--green-avatar-color"],
["--dark-green-avatar-bg", "--dark-green-avatar-color"],
["--red-avatar-bg", "--red-avatar-color"],
["--yellow-avatar-bg", "--yellow-avatar-color"],
["--purple-avatar-bg", "--purple-avatar-color"],
["--gray-avatar-bg", "--gray-avatar-color0"],
]
encoded_name = str(full_name).encode("utf-8")
hash_name = hashlib.md5(encoded_name).hexdigest()
idx = cint((int(hash_name[4:6], 16) + 1) / 5.33)
return palette[idx % 8]
@frappe.whitelist(allow_guest=True)
def sign_up(email, full_name, verify_terms, user_category):
if is_signup_disabled():
frappe.throw(_("Sign Up is disabled"), _("Not Allowed"))
user = frappe.db.get("User", {"email": email})
if user:
if user.enabled:
return 0, _("Already Registered")
else:
return 0, _("Registered but disabled")
else:
if frappe.db.get_creation_count("User", 60) > 300:
frappe.respond_as_web_page(
_("Temporarily Disabled"),
_(
"Too many users signed up recently, so the registration is disabled. Please try back in an hour"
),
http_status_code=429,
)
user = frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": escape_html(full_name),
"verify_terms": verify_terms,
"user_category": user_category,
"country": "",
"enabled": 1,
"new_password": random_string(10),
"user_type": "Website User",
}
)
user.flags.ignore_permissions = True
user.flags.ignore_password_policy = True
user.insert()
# set default signup role as per Portal Settings
default_role = frappe.db.get_value("Portal Settings", None, "default_role")
if default_role:
user.add_roles(default_role)
user.add_roles("LMS Student")
set_country_from_ip(None, user.name)
if user.flags.email_sent:
return 1, _("Please check your email for verification")
else:
return 2, _("Please ask your administrator to verify your sign-up")
def set_country_from_ip(login_manager=None, user=None):
if not user and login_manager:
user = login_manager.user
user_country = frappe.db.get_value("User", user, "country")
# if user_country:
# return
frappe.db.set_value("User", user, "country", get_country_code())
return
def on_session_creation(login_manager):
if frappe.db.get_single_value(
"System Settings", "setup_complete"
) and frappe.db.get_single_value("LMS Settings", "default_home"):
frappe.local.response["home_page"] = "/lms"
@frappe.whitelist()
def search_users(start: int = 0, text: str = ""):
start = cint(start)
search_text = frappe.db.escape(f"%{text}%")
or_filters = get_or_filters(search_text)
count = len(get_users(or_filters, 0, 900000000))
users = get_users(or_filters, start, 24)
user_details = get_user_details(users)
return {"user_details": user_details, "start": start + 24, "count": count}
def get_or_filters(text):
user_fields = [
"first_name",
"last_name",
"full_name",
"email",
"preferred_location",
"dream_companies",
]
education_fields = ["institution_name", "location", "degree_type", "major"]
work_fields = ["title", "company"]
certification_fields = ["certification_name", "organization"]
or_filters = []
if text:
for field in user_fields:
or_filters.append(f"u.{field} like {text}")
for field in education_fields:
or_filters.append(f"ed.{field} like {text}")
for field in work_fields:
or_filters.append(f"we.{field} like {text}")
for field in certification_fields:
or_filters.append(f"c.{field} like {text}")
or_filters.append(f"s.skill_name like {text}")
or_filters.append(f"pf.function like {text}")
or_filters.append(f"pi.industry like {text}")
return "AND ({})".format(" OR ".join(or_filters)) if or_filters else ""
def get_user_details(users):
user_details = []
for user in users:
details = frappe.db.get_value(
"User",
user,
["name", "username", "full_name", "user_image", "headline", "looking_for_job"],
as_dict=True,
)
user_details.append(Widgets().MemberCard(member=details, avatar_class="avatar-large"))
return user_details
def get_users(or_filters, start, page_length):
users = frappe.db.sql(
"""
SELECT DISTINCT u.name
FROM `tabUser` u
LEFT JOIN `tabEducation Detail` ed
ON u.name = ed.parent
LEFT JOIN `tabWork Experience` we
ON u.name = we.parent
LEFT JOIN `tabCertification` c
ON u.name = c.parent
LEFT JOIN `tabSkills` s
ON u.name = s.parent
LEFT JOIN `tabPreferred Function` pf
ON u.name = pf.parent
LEFT JOIN `tabPreferred Industry` pi
ON u.name = pi.parent
WHERE u.enabled = True {or_filters}
ORDER BY u.creation desc
LIMIT {start}, {page_length}
""".format(
or_filters=or_filters, start=start, page_length=page_length
),
as_dict=1,
)
return users
@frappe.whitelist()
def save_role(user, role, value):
frappe.only_for("Moderator")
if cint(value):
doc = frappe.get_doc(
{
"doctype": "Has Role",
"parent": user,
"role": role,
"parenttype": "User",
"parentfield": "roles",
}
)
doc.save(ignore_permissions=True)
else:
frappe.db.delete("Has Role", {"parent": user, "role": role})
return True

View File

@@ -6,6 +6,7 @@ import re
import os
import mimetypes
import frappe
from frappe.utils import get_files_path
from frappe.website.page_renderers.base_renderer import BaseRenderer
from frappe.website.page_renderers.document_page import DocumentPage
from frappe.website.page_renderers.list_page import ListPage
@@ -173,3 +174,23 @@ class SCORMRenderer(BaseRenderer):
)
response.mimetype = mimetypes.guess_type(index_path)[0]
return response
elif not os.path.exists(path):
chapter_folder = "/".join(self.path.split("/")[:3])
chapter_folder_path = os.path.realpath(
frappe.get_site_path("public", chapter_folder)
)
file = path.split("/")[-1]
correct_file_path = None
for root, dirs, files in os.walk(chapter_folder_path):
if file in files:
correct_file_path = os.path.join(root, file)
break
if correct_file_path:
f = open(correct_file_path, "rb")
response = Response(
wrap_file(frappe.local.request.environ, f), direct_passthrough=True
)
response.mimetype = mimetypes.guess_type(correct_file_path)[0]
return response

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

View File

@@ -85,7 +85,7 @@
}
frappe.call({
method: "lms.overrides.user.sign_up",
method: "lms.lms.user.sign_up",
args: {
"email": email,
"full_name": full_name,

3071
yarn.lock

File diff suppressed because it is too large Load Diff