Compare commits

...

50 Commits

Author SHA1 Message Date
Frappe PR Bot
b74c1670ca chore(release): Bumped to Version 2.6.0 2024-09-25 05:47:45 +00:00
Jannat Patel
04552bdef6 Merge pull request #1025 from pateljannat/categories-in-courses
feat: course categories
2024-09-24 12:55:41 +05:30
Jannat Patel
ad5bf89b35 test: enter instructor value 2024-09-24 12:32:07 +05:30
Jannat Patel
88b38dfd83 test: category test in course form in UI 2024-09-24 12:22:34 +05:30
Jannat Patel
75e9ca395f chore: removed unnecessary custom fields 2024-09-24 10:59:27 +05:30
Jannat Patel
6fb206cc4e feat: updating category from settings 2024-09-24 10:33:23 +05:30
Jannat Patel
62cb198492 Merge branch 'develop' of https://github.com/frappe/lms into categories-in-courses 2024-09-23 19:23:49 +05:30
Jannat Patel
9609329f01 Merge pull request #1028 from pateljannat/fix-signup-customisations
fix: signup conditions
2024-09-23 19:12:29 +05:30
Jannat Patel
c93808af94 fix: signup conditions 2024-09-23 18:41:35 +05:30
Jannat Patel
58866260ec Merge branch 'develop' of https://github.com/frappe/lms into categories-in-courses 2024-09-23 18:39:19 +05:30
Jannat Patel
e6157ff411 Merge pull request #1027 from pateljannat/refactor-signup-customisations
refactor: signup customisations
2024-09-23 18:23:04 +05:30
Jannat Patel
8cca8920ee chore: removed unnecessary file 2024-09-23 18:07:08 +05:30
Jannat Patel
ab039dbd46 refactor: signup customisations 2024-09-23 18:04:36 +05:30
Jannat Patel
9853ab3fd9 Merge pull request #1024 from frappe/pot_develop_2024-09-20
chore: update POT file
2024-09-23 16:11:57 +05:30
Jannat Patel
dc2bf9f13e feat: category settings 2024-09-23 16:11:17 +05:30
Jannat Patel
7c90ca4040 feat: category in settings 2024-09-20 22:15:59 +05:30
frappe-pr-bot
75a90e1f39 chore: update POT file 2024-09-20 16:04:14 +00:00
Jannat Patel
bc4b17cc3d Merge pull request #1022 from pateljannat/evaluator_name_issue
fix: evaluator name issue
2024-09-19 15:18:05 +05:30
Jannat Patel
8c454a333e fix: evaluator name issue 2024-09-19 14:19:55 +05:30
Jannat Patel
3cda563583 fix: padding of settings modal 2024-09-18 14:46:29 +05:30
Jannat Patel
545326a02a Merge pull request #1021 from pateljannat/fix-help-video-url
fix: url for lesson help videos
2024-09-18 14:42:33 +05:30
Jannat Patel
14ce5d7e23 fix: url for lesson help videos 2024-09-18 11:49:38 +05:30
Frappe PR Bot
b6422d1046 chore(release): Bumped to Version 2.5.0 2024-09-18 04:46:24 +00:00
Jannat Patel
7196bbe221 Merge pull request #1019 from pateljannat/issues-34
fix: misc issues
2024-09-18 09:03:33 +05:30
Jannat Patel
bed16c3726 fix: condition to show course addition button 2024-09-18 08:56:35 +05:30
Jannat Patel
d18ca232e3 fix: misc issues 2024-09-18 08:43:16 +05:30
Jannat Patel
d1200d0fa9 fix: profile page empty bio error 2024-09-17 11:48:49 +05:30
Jannat Patel
d1c88b306f Merge pull request #1018 from pateljannat/batch-quiz
feat: quiz page
2024-09-17 10:42:34 +05:30
Jannat Patel
7f2723f9cb Merge pull request #1014 from pateljannat/batch-welcome-email-fix
fix: batch confirmation email trigger
2024-09-17 10:30:00 +05:30
Jannat Patel
8df4bef71a feat: quiz page 2024-09-17 10:25:59 +05:30
Jannat Patel
aa87622606 Merge pull request #1017 from pateljannat/refactor-lesson-editor
refactor: adding quiz and uploading content to a lesson
2024-09-16 19:19:42 +05:30
Jannat Patel
b91339fe28 fix: removed unnecessary files 2024-09-16 18:50:06 +05:30
Jannat Patel
17d4973ab8 test: fixed course creation 2024-09-16 18:41:25 +05:30
Jannat Patel
3c12548420 Merge pull request #1016 from frappe/pot_develop_2024-09-13
chore: update POT file
2024-09-16 16:50:16 +05:30
Jannat Patel
20c10f1645 feat: help guide videos 2024-09-16 16:49:17 +05:30
Jannat Patel
a7843e0e3a refactor: uploading content in lesson 2024-09-16 10:33:16 +05:30
frappe-pr-bot
169ea4385f chore: update POT file 2024-09-13 16:04:09 +00:00
Jannat Patel
9549f3a3ed Merge pull request #1015 from pateljannat/course-completion-funnel
chore: analytics for course completion
2024-09-13 11:37:22 +05:30
Jannat Patel
ba66c2549f chore: renamed lesson_progress to course_progress for analytics 2024-09-13 11:21:59 +05:30
Jannat Patel
76c3e630cc chore: analytics for course completion 2024-09-13 11:18:17 +05:30
Jannat Patel
7a0b952638 style: trigger condition 2024-09-13 09:57:41 +05:30
Jannat Patel
5966a3edad fix: batch confirmation email trigger 2024-09-13 09:57:06 +05:30
Jannat Patel
d44c7cd9fc Merge pull request #1010 from pateljannat/evaluation-calendar
feat: Evaluation and Certification from Learning Portal
2024-09-12 16:48:51 +05:30
Jannat Patel
46553987ac fix: certification tab title 2024-09-12 16:22:43 +05:30
Jannat Patel
45725f1f6e chore: bumped up frappe-ui 2024-09-12 14:31:55 +05:30
Jannat Patel
58369ba65e fix: evaluator info and other styles 2024-09-11 19:51:14 +05:30
Jannat Patel
5ce67dda2e Merge pull request #1002 from prachi8848/cusrsor
feat: added a cusrsor
2024-09-11 15:05:48 +05:30
Jannat Patel
7da608ed44 feat: certification details and form 2024-09-10 21:00:49 +05:30
Jannat Patel
60f2e86b42 feat: evaluation feedback record 2024-09-09 20:05:08 +05:30
sonali8848
a2025c0571 feat: added a cusrsor 2024-09-02 09:41:22 +00:00
76 changed files with 9021 additions and 3677 deletions

2
.gitmodules vendored
View File

@@ -1,3 +1,3 @@
[submodule "frappe-ui"] [submodule "frappe-ui"]
path = frappe-ui path = frappe-ui
url = https://github.com/pateljannat/frappe-ui url = https://github.com/frappe/frappe-ui

View File

@@ -31,12 +31,35 @@ describe("Course Creation", () => {
.contains("Preview Video") .contains("Preview Video")
.type("https://www.youtube.com/embed/-LPmw2Znl2c"); .type("https://www.youtube.com/embed/-LPmw2Znl2c");
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}"); cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
cy.get(".search-input").click().type("frappe"); cy.get("label")
cy.wait(1000); .contains("Category")
.parent()
.within(() => {
cy.get("button").click();
});
cy.get("[id^=headlessui-combobox-option-") cy.get("[id^=headlessui-combobox-option-")
.should("be.visible") .should("be.visible")
.first() .first()
.click(); .click();
/* Instructor */
cy.get("label")
.contains("Instructors")
.parent()
.within(() => {
cy.get("input").click().type("frappe");
cy.get("input")
.invoke("attr", "aria-controls")
.as("instructor_list_id");
});
cy.get("@instructor_list_id").then((instructor_list_id) => {
cy.get(`[id^=${instructor_list_id}`)
.should("be.visible")
.within(() => {
cy.get("[id^=headlessui-combobox-option-").first().click();
});
});
cy.get("label").contains("Published").click(); cy.get("label").contains("Published").click();
cy.get("label").contains("Published On").type("2021-01-01"); cy.get("label").contains("Published On").type("2021-01-01");
cy.button("Save").click(); cy.button("Save").click();
@@ -61,21 +84,7 @@ describe("Course Creation", () => {
cy.wait(1000); cy.wait(1000);
cy.get("label").contains("Title").type("Test Lesson"); cy.get("label").contains("Title").type("Test Lesson");
/* cy.get("#content .ce-block")
.click()
.invoke("text", "https://www.youtube.com/watch?v=GoDtyItReto"); */
/* cy.get("#content .ce-block")
.click()
.paste("https://www.youtube.com/watch?v=GoDtyItReto"); */
cy.fixture("Youtube.mov", "base64").then((fileContent) => {
cy.get('input[type="file"]').attachFile({
fileContent,
fileName: "Youtube.mov",
mimeType: "image/png",
encoding: "base64",
});
});
cy.get("#content .ce-block").type( 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." "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."
); );
@@ -119,12 +128,6 @@ describe("Course Creation", () => {
cy.url().should("include", "/learn/1-1"); cy.url().should("include", "/learn/1-1");
cy.get("div").contains("Test Lesson"); cy.get("div").contains("Test Lesson");
cy.get("video")
.should("be.visible")
.children("source")
.invoke("attr", "src")
.should("include", "/files/Youtube");
cy.get("div").contains( cy.get("div").contains(
"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." "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."
); );

1
frappe-ui Submodule

Submodule frappe-ui added at 8cd9b06a5e

View File

@@ -19,9 +19,10 @@
"@editorjs/paragraph": "^2.11.3", "@editorjs/paragraph": "^2.11.3",
"@editorjs/simple-image": "^1.6.0", "@editorjs/simple-image": "^1.6.0",
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"frappe-ui": "^0.1.56", "frappe-ui": "^0.1.69",
"lucide-vue-next": "^0.383.0", "lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
"pinia": "^2.0.33", "pinia": "^2.0.33",

BIN
frontend/public/Quiz.mp4 Normal file

Binary file not shown.

BIN
frontend/public/Upload.mp4 Normal file

Binary file not shown.

Binary file not shown.

BIN
frontend/public/Youtube.mp4 Normal file

Binary file not shown.

View File

@@ -1,49 +1,92 @@
<template> <template>
<div> <div>
<div class="flex items-center justify-between">
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Assessments') }} {{ __('Assessments') }}
</div> </div>
<Button v-if="canSeeAddButton()" @click="showModal = true">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
</div>
<div v-if="assessments.data?.length"> <div v-if="assessments.data?.length">
<ListView <ListView
:columns="getAssessmentColumns()" :columns="getAssessmentColumns()"
:rows="assessments.data" :rows="assessments.data"
row-key="name" row-key="name"
:options="{ :options="{
selectable: false,
showTooltip: false, showTooltip: false,
getRowRoute: (row) => { getRowRoute: (row) => getRowRoute(row),
if (row.submission) {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: row.submission.name,
},
}
} else {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: 'new',
},
}
}
},
}" }"
> >
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
>
<ListHeaderItem :item="item" v-for="item in getAssessmentColumns()">
<template #prefix="{ item }">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in assessments.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAssessments(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView> </ListView>
</div> </div>
<div v-else class="text-sm italic text-gray-600"> <div v-else class="text-sm italic text-gray-600">
{{ __('No Assessments') }} {{ __('No Assessments') }}
</div> </div>
</div> </div>
<AssessmentModal
v-model="showModal"
v-model:assessments="assessments"
:batch="props.batch"
/>
</template> </template>
<script setup> <script setup>
import { ListView, createResource } from 'frappe-ui' import {
import { inject } from 'vue' ListView,
ListRow,
ListRows,
ListHeader,
ListHeaderItem,
ListRowItem,
ListSelectBanner,
createResource,
Button,
} from 'frappe-ui'
import { inject, ref } from 'vue'
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
import { Plus, Trash2 } from 'lucide-vue-next'
const user = inject('$user') const user = inject('$user')
const showModal = ref(false)
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -74,6 +117,61 @@ const assessments = createResource({
auto: true, auto: true,
}) })
const deleteAssessments = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'LMS Assessment',
documents: values.assessments,
}
},
})
const removeAssessments = (selections, unselectAll) => {
deleteAssessments.submit(
{ assessments: Array.from(selections) },
{
onSuccess(data) {
assessments.reload()
unselectAll()
},
}
)
}
const getRowRoute = (row) => {
if (row.assessment_type == 'LMS Assignment') {
if (row.submission) {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: row.submission.name,
},
}
} else {
return {
name: 'AssignmentSubmission',
params: {
assignmentName: row.assessment_name,
submissionName: 'new',
},
}
}
} else {
return {
name: 'Quiz',
params: {
quizID: row.assessment_name,
},
}
}
}
const canSeeAddButton = () => {
return user.data?.is_moderator || user.data?.is_evaluator
}
const getAssessmentColumns = () => { const getAssessmentColumns = () => {
let columns = [ let columns = [
{ {

View File

@@ -4,15 +4,11 @@
<div class="text-xl font-semibold"> <div class="text-xl font-semibold">
{{ __('Courses') }} {{ __('Courses') }}
</div> </div>
<Button <Button v-if="canSeeAddButton()" @click="openCourseModal()">
v-if="user.data?.is_moderator"
variant="solid"
@click="openCourseModal()"
>
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
{{ __('Add Course') }} {{ __('Add') }}
</Button> </Button>
</div> </div>
<div v-if="courses.data?.length"> <div v-if="courses.data?.length">
@@ -88,6 +84,7 @@ import {
ListRowItem, ListRowItem,
} from 'frappe-ui' } from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next' import { Plus, Trash2 } from 'lucide-vue-next'
import { showToast } from '@/utils'
const showCourseModal = ref(false) const showCourseModal = ref(false)
const user = inject('$user') const user = inject('$user')
@@ -132,23 +129,32 @@ const getCoursesColumns = () => {
] ]
} }
const removeCourse = createResource({ const deleteCourses = createResource({
url: 'frappe.client.delete', url: 'lms.lms.api.delete_documents',
makeParams(values) { makeParams(values) {
return { return {
doctype: 'Batch Course', doctype: 'Batch Course',
name: values.course, documents: values.courses,
} }
}, },
}) })
const removeCourses = (selections, unselectAll) => { const removeCourses = (selections, unselectAll) => {
selections.forEach(async (course) => { deleteCourses.submit(
removeCourse.submit({ course }) {
}) courses: Array.from(selections),
setTimeout(() => { },
{
onSuccess(data) {
courses.reload() courses.reload()
showToast(__('Success'), __('Courses deleted successfully'), 'check')
unselectAll() unselectAll()
}, 1000) },
}
)
}
const canSeeAddButton = () => {
return user.data?.is_moderator || user.data?.is_evaluator
} }
</script> </script>

View File

@@ -1,9 +1,9 @@
<template> <template>
<Button class="float-right mb-3" variant="solid" @click="openStudentModal()"> <Button class="float-right mb-3" @click="openStudentModal()">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
{{ __('Add Student') }} {{ __('Add') }}
</Button> </Button>
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Students') }} {{ __('Students') }}
@@ -88,6 +88,7 @@ import {
import { Trash2, Plus } from 'lucide-vue-next' import { Trash2, Plus } from 'lucide-vue-next'
import { ref } from 'vue' import { ref } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue' import StudentModal from '@/components/Modals/StudentModal.vue'
import { showToast } from '@/utils'
const showStudentModal = ref(false) const showStudentModal = ref(false)
@@ -135,23 +136,28 @@ const openStudentModal = () => {
showStudentModal.value = true showStudentModal.value = true
} }
const removeStudent = createResource({ const deleteStudents = createResource({
url: 'frappe.client.delete', url: 'lms.lms.api.delete_documents',
makeParams(values) { makeParams(values) {
return { return {
doctype: 'Batch Student', doctype: 'Batch Student',
name: values.student, documents: values.students,
} }
}, },
}) })
const removeStudents = (selections, unselectAll) => { const removeStudents = (selections, unselectAll) => {
selections.forEach(async (student) => { deleteStudents.submit(
removeStudent.submit({ student }) {
}) students: Array.from(selections),
setTimeout(() => { },
{
onSuccess(data) {
students.reload() students.reload()
showToast(__('Success'), __('Students deleted successfully'), 'check')
unselectAll() unselectAll()
}, 500) },
}
)
} }
</script> </script>

View File

@@ -0,0 +1,151 @@
<template>
<div class="flex flex-col min-h-0">
<div class="flex items-center justify-between">
<div class="text-xl font-semibold mb-1">
{{ label }}
</div>
<Button @click="() => showCategoryForm()">
<template #icon>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
<X v-else class="h-3 w-3 stroke-1.5" />
</template>
</Button>
</div>
<div
v-if="showForm"
class="flex items-center justify-between my-4 space-x-2"
>
<FormControl
ref="categoryInput"
v-model="category"
:placeholder="__('Category Name')"
class="flex-1"
/>
<Button @click="addCategory()" variant="subtle">
{{ __('Add') }}
</Button>
</div>
<div class="overflow-y-scroll">
<div class="text-base divide-y">
<FormControl
:value="cat.category"
type="text"
v-for="cat in categories.data"
class="form-control"
@change.stop="(e) => update(cat.name, e.target.value)"
/>
</div>
</div>
</div>
</template>
<script setup>
import {
Button,
FormControl,
createListResource,
createResource,
debounce,
} from 'frappe-ui'
import { Plus, X } from 'lucide-vue-next'
import { ref } from 'vue'
const showForm = ref(false)
const category = ref(null)
const categoryInput = ref(null)
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
})
const categories = createListResource({
doctype: 'LMS Category',
fields: ['name', 'category'],
auto: true,
})
const newCategory = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Category',
category: category.value,
},
}
},
})
const addCategory = () => {
newCategory.submit(
{},
{
onSuccess(data) {
categories.reload()
category.value = null
},
}
)
}
const showCategoryForm = () => {
showForm.value = !showForm.value
setTimeout(() => {
categoryInput.value.$el.querySelector('input').focus()
}, 0)
}
const updateCategory = createResource({
url: 'frappe.client.rename_doc',
makeParams(values) {
return {
doctype: 'LMS Category',
old_name: values.name,
new_name: values.category,
}
},
})
const update = (name, value) => {
updateCategory.submit(
{
name: name,
category: value,
},
{
onSuccess() {
categories.reload()
},
}
)
}
</script>
<style>
.form-control input {
padding: 1.25rem 0;
border-color: transparent;
background: white;
}
.form-control input:focus {
outline: transparent;
background: white;
box-shadow: none;
border-color: transparent;
}
.form-control input:hover {
outline: transparent;
background: white;
box-shadow: none;
border-color: transparent;
}
</style>

View File

@@ -108,6 +108,7 @@ const options = createResource({
url: 'frappe.desk.search.search_link', url: 'frappe.desk.search.search_link',
cache: [props.doctype, text.value], cache: [props.doctype, text.value],
method: 'POST', method: 'POST',
auto: true,
params: { params: {
txt: text.value, txt: text.value,
doctype: props.doctype, doctype: props.doctype,

View File

@@ -1,18 +1,27 @@
<template> <template>
<div class="space-y-1">
<label class="block text-xs text-gray-600" v-if="props.label">
{{ props.label }}
</label>
<div class="flex text-center"> <div class="flex text-center">
<div v-for="index in 5"> <div
v-for="index in 5"
@mouseover="hoveredRating = index"
@mouseleave="hoveredRating = 0"
>
<Star <Star
:class="index <= rating ? 'fill-orange-500' : ''" class="fill-gray-400 text-gray-50 stroke-1 mr-1 cursor-pointer"
class="h-6 w-6 fill-gray-400 text-gray-50 mr-1 cursor-pointer" :class="iconClasses(index)"
@click="markRating(index)" @click="markRating(index)"
/> />
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { Star } from 'lucide-vue-next' import { Star } from 'lucide-vue-next'
import { ref } from 'vue' import { computed, ref, watch } from 'vue'
const props = defineProps({ const props = defineProps({
id: { id: {
@@ -23,10 +32,36 @@ const props = defineProps({
type: Number, type: Number,
default: 0, default: 0,
}, },
label: {
type: String,
default: '',
},
size: {
type: String,
default: 'md',
},
}) })
const iconClasses = (index) => {
let classes = [
{
sm: 'size-4',
md: 'size-5',
lg: 'size-6',
xl: 'size-7',
}[props.size],
]
if (index <= hoveredRating.value && index > rating.value) {
classes.push('fill-yellow-200')
} else if (index <= rating.value) {
classes.push('fill-yellow-500')
}
return classes.join(' ')
}
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
let rating = ref(props.modelValue) const rating = ref(props.modelValue)
const hoveredRating = ref(0)
let emitChange = (value) => { let emitChange = (value) => {
emit('update:modelValue', value) emit('update:modelValue', value)
@@ -36,4 +71,11 @@ function markRating(index) {
emitChange(index) emitChange(index)
rating.value = index rating.value = index
} }
watch(
() => props.modelValue,
(newVal) => {
rating.value = newVal
}
)
</script> </script>

View File

@@ -117,6 +117,7 @@ import { BookOpen, Users, Star } from 'lucide-vue-next'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { Button, createResource } from 'frappe-ui' import { Button, createResource } from 'frappe-ui'
import { createToast } from '@/utils/' import { createToast } from '@/utils/'
import { capture } from '@/telemetry'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
@@ -155,6 +156,9 @@ function enrollStudent() {
course: props.course.data.name, course: props.course.data.name,
}) })
.then(() => { .then(() => {
capture('enrolled_in_course', {
course: props.course.data.name,
})
createToast({ createToast({
title: 'Enrolled Successfully', title: 'Enrolled Successfully',
icon: 'check', icon: 'check',

View File

@@ -4,7 +4,7 @@
v-if="title && (outline.data?.length || allowEdit)" v-if="title && (outline.data?.length || allowEdit)"
class="grid grid-cols-[70%,30%] mb-4 px-2" class="grid grid-cols-[70%,30%] mb-4 px-2"
> >
<div class="font-semibold text-lg"> <div class="font-semibold text-lg leading-5">
{{ __(title) }} {{ __(title) }}
</div> </div>
<Button size="sm" v-if="allowEdit" @click="openChapterModal()"> <Button size="sm" v-if="allowEdit" @click="openChapterModal()">

View File

@@ -0,0 +1,74 @@
<template>
<div class="space-y-5">
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('quiz')"
>
<span>
{{ __('How to add a Quiz?') }}
</span>
<Info class="w-3 h-3 text-gray-700" />
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('upload')"
>
<span class="leading-5">
{{ __('How to upload content from your system?') }}
</span>
<Info class="w-3 h-3 text-gray-700" />
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.'
)
}}
</div>
</div>
<div class="space-y-2">
<div
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
@click="openHelpDialog('youtube')"
>
<span>
{{ __('How to add a YouTube Video?') }}
</span>
<Info class="w-3 h-3 text-gray-700" />
</div>
<div class="text-xs text-gray-600 mb-1 leading-5">
{{
__(
'Copy the URL of the video from YouTube and paste it in the editor.'
)
}}
</div>
</div>
</div>
<ExplanationVideos v-model="showExplanation" :type="type" />
</template>
<script setup>
import { Info } from 'lucide-vue-next'
import { ref } from 'vue'
import ExplanationVideos from '@/components/Modals/ExplanationVideos.vue'
const showExplanation = ref(false)
const type = ref(null)
const openHelpDialog = (contentType) => {
type.value = contentType
showExplanation.value = true
}
</script>

View File

@@ -1,174 +0,0 @@
<template>
<div class="text-lg font-semibold">
{{ __('Components') }}
</div>
<div class="mt-5 space-y-4">
<Tooltip
:text="
__(
'Content such as quiz, video and image will be added in the editor you select.'
)
"
placement="bottom"
>
<div class="">
<div class="text-xs text-gray-600 mb-1">
{{ __('Select an Editor') }}
</div>
<Select v-model="currentEditor" :options="getEditorOptions()" />
</div>
</Tooltip>
<div class="flex">
<Link
:value="quiz"
class="flex-1"
doctype="LMS Quiz"
:label="__('Add an existing quiz')"
@change="(option) => addQuiz(option)"
/>
<router-link
:to="{
name: 'QuizCreation',
params: {
quizID: 'new',
},
}"
class="self-end ml-2"
>
<Button>
<template #icon>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</router-link>
</div>
<div class="">
<div class="text-xs text-gray-600 mb-1">
{{ __('Add an image, video, pdf or audio.') }}
</div>
<div class="flex">
<FileUploader
v-if="!file"
:fileTypes="['image/*', 'video/*', 'audio/*', '.pdf']"
:validateFile="validateFile"
@success="(data) => addFile(data)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading
? __('Uploading {0}%').format(progress)
: __('Upload a File')
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="">
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-4 w-4 stroke-1.5 text-gray-700" />
</div>
<div class="flex flex-col">
<span class="text-xs">
{{ file.file_name }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="">
<div class="text-xs text-gray-600 mb-1">
{{
__(
'To add a YouTube video, paste the URL of the video in the editor.'
)
}}
</div>
<YouTubeExplanation>
<template v-slot="{ togglePopover }">
<div
@click="togglePopover()"
class="flex items-center text-sm underline cursor-pointer"
>
<Info class="w-3 h-3 stroke-1.5 text-gray-700 mr-1" />
{{ __('Learn More') }}
</div>
</template>
</YouTubeExplanation>
</div>
</div>
</template>
<script setup>
import Link from '@/components/Controls/Link.vue'
import { FileUploader, Button, Select, Tooltip } from 'frappe-ui'
import { Plus, FileText, Info } from 'lucide-vue-next'
import { ref, watch } from 'vue'
import YouTubeExplanation from '@/components/Modals/YouTubeExplanation.vue'
const quiz = ref(null)
const file = ref(null)
const lessonEditor = ref(null)
const instructorEditor = ref(null)
const currentEditor = ref('Lesson Content')
const props = defineProps({
editor: {
required: true,
},
notesEditor: {
required: true,
},
})
const addQuiz = (value) => {
getCurrentEditor().caret.setToLastBlock('end', 0)
if (value) {
getCurrentEditor().blocks.insert('quiz', {
quiz: value,
})
quiz.value = null
}
}
const addFile = (data) => {
getCurrentEditor().caret.setToLastBlock('end', 0)
getCurrentEditor().blocks.insert('upload', data)
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3', 'pdf'].includes(extension)) {
return 'Only image and video files are allowed.'
}
}
const getEditorOptions = () => {
return [
{
label: 'Lesson Content',
value: 'Lesson Content',
},
{
label: 'Instructor Content',
value: 'Instructor Content',
},
]
}
const getCurrentEditor = () => {
return currentEditor.value == 'Lesson Content'
? lessonEditor.value
: instructorEditor.value
}
watch(
() => [props.editor, props.notesEditor],
([newEditor, newNotesEditor], [oldEditor, oldNotesEditor]) => {
lessonEditor.value = newEditor
instructorEditor.value = newNotesEditor
}
)
</script>

View File

@@ -0,0 +1,86 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add an assessment'),
size: 'sm',
actions: [
{
label: __('Submit'),
variant: 'solid',
onClick: (close) => addAssessment(close),
},
],
}"
>
<template #body-content>
<div class="space-y-4">
<FormControl
type="select"
:options="assessmentTypes"
v-model="assessmentType"
:label="__('Type')"
/>
<Link
v-model="assessment"
:doctype="assessmentType"
:label="__('Assessment')"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, FormControl, createResource } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { computed, ref } from 'vue'
import { showToast } from '@/utils'
const show = defineModel()
const assessmentType = ref(null)
const assessment = ref(null)
const assessments = defineModel('assessments')
const props = defineProps({
batch: {
type: String,
default: null,
},
})
const assessmentResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Assessment',
parent: props.batch,
parenttype: 'LMS Batch',
parentfield: 'assessment',
assessment_type: assessmentType.value,
assessment_name: assessment.value,
},
}
},
})
const addAssessment = (close) => {
assessmentResource.submit(
{},
{
onSuccess(data) {
assessments.value.reload()
showToast(__('Success'), __('Assessment added successfully'), 'check')
close()
},
}
)
}
const assessmentTypes = computed(() => {
return [
{ label: 'Quiz', value: 'LMS Quiz' },
{ label: 'Assignment', value: 'LMS Assignment' },
]
})
</script>

View File

@@ -15,18 +15,24 @@
}" }"
> >
<template #body-content> <template #body-content>
<FormControl label="Title" v-model="chapter.title" class="mb-4" /> <FormControl
ref="chapterInput"
label="Title"
v-model="chapter.title"
class="mb-4"
/>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, FormControl, createResource } from 'frappe-ui' import { Dialog, FormControl, createResource } from 'frappe-ui'
import { defineModel, reactive, watch } from 'vue' import { defineModel, reactive, watch, ref } from 'vue'
import { createToast } from '@/utils/' import { createToast } from '@/utils/'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
const show = defineModel() const show = defineModel()
const outline = defineModel('outline') const outline = defineModel('outline')
const chapterInput = ref(null)
const props = defineProps({ const props = defineProps({
course: { course: {
@@ -37,6 +43,7 @@ const props = defineProps({
type: Object, type: Object,
}, },
}) })
const chapter = reactive({ const chapter = reactive({
title: '', title: '',
}) })
@@ -97,6 +104,7 @@ const addChapter = (close) => {
{ name: data.name }, { name: data.name },
{ {
onSuccess(data) { onSuccess(data) {
chapter.title = ''
outline.value.reload() outline.value.reload()
createToast({ createToast({
text: 'Chapter added successfully', text: 'Chapter added successfully',
@@ -160,4 +168,12 @@ watch(
chapter.title = newChapter?.title chapter.title = newChapter?.title
} }
) )
watch(show, () => {
if (show.value) {
setTimeout(() => {
chapterInput.value.$el.querySelector('input').focus()
}, 100)
}
})
</script> </script>

View File

@@ -0,0 +1,378 @@
<template>
<Dialog
v-model="show"
:options="{
size: '2xl',
}"
>
<template #body>
<div class="flex text-base">
<div class="flex flex-col w-1/2 p-5">
<div class="text-lg font-semibold mb-4">
{{ event.title }}
</div>
<div class="flex flex-col space-y-4 text-sm text-gray-800">
<Tooltip :text="__('Email ID')">
<div class="flex items-center space-x-2 w-fit">
<User class="h-4 w-4 stroke-1.5" />
<span>
{{ event.member }}
</span>
</div>
</Tooltip>
<Tooltip :text="__('Course')">
<div class="flex items-center space-x-2 w-fit">
<BookOpen class="h-4 w-4 stroke-1.5" />
<span>
{{ event.course_title }}
</span>
</div>
</Tooltip>
<Tooltip v-if="event.batch_title" :text="__('Batch')">
<div class="flex items-center space-x-2 w-fit">
<Users class="h-4 w-4 stroke-1.5" />
<span>
{{ event.batch_title }}
</span>
</div>
</Tooltip>
<Tooltip :text="__('Date')">
<div class="flex items-center space-x-2 w-fit">
<Calendar class="h-4 w-4 stroke-1.5" />
<span>
{{ dayjs(event.date).format('DD MMM YYYY') }}
</span>
</div>
</Tooltip>
<Tooltip :text="__('Time')">
<div class="flex items-center space-x-2 w-fit">
<Clock class="h-4 w-4 stroke-1.5" />
<span>
{{ formatTime(event.start_time) }} -
{{ formatTime(event.end_time) }}
</span>
</div>
</Tooltip>
</div>
<div class="flex items-center space-x-2 mt-auto">
<Button
v-if="certificate.name"
@click="openCertificate(certificate)"
class="w-full"
>
<template #prefix>
<FileText class="h-4 w-4 stroke-1.5" />
</template>
{{ __('View Certificate') }}
</Button>
<Button v-else @click="openCallLink(event.venue)" class="w-full">
<template #prefix>
<Video class="h-4 w-4 stroke-1.5" />
</template>
<span>
{{ __('Join Meeting') }}
</span>
</Button>
</div>
</div>
<Tabs :tabs="tabs" v-model="tabIndex" class="border-l w-1/2">
<template #default="{ tab }">
<div
v-if="tab.label == 'Evaluation'"
class="flex flex-col space-y-4 p-5"
>
<div class="flex items-center justify-between">
<Rating v-model="evaluation.rating" :label="__('Rating')" />
<FormControl
type="select"
:options="statusOptions"
v-model="evaluation.status"
:label="__('Status')"
class="w-1/2"
/>
</div>
<Textarea
v-model="evaluation.summary"
:label="__('Summary')"
:rows="7"
/>
<Button variant="solid" @click="saveEvaluation()">
{{ __('Save') }}
</Button>
</div>
<div v-else class="flex flex-col space-y-4 p-5">
<FormControl
type="checkbox"
v-model="certificate.published"
:label="__('Published')"
/>
<Link
v-model="certificate.template"
:label="__('Template')"
doctype="Print Format"
:filters="{
doc_type: 'LMS Certificate',
}"
/>
<FormControl
type="date"
v-model="certificate.issue_date"
:label="__('Issue Date')"
/>
<FormControl
type="date"
v-model="certificate.expiry_date"
:label="__('Expiry Date')"
/>
<Button variant="solid" @click="saveCertificate()">
{{ __('Save') }}
</Button>
</div>
</template>
</Tabs>
</div>
</template>
</Dialog>
</template>
<script setup>
import {
Dialog,
Button,
FormControl,
createResource,
Tabs,
Tooltip,
Textarea,
} from 'frappe-ui'
import {
User,
Calendar,
Clock,
Video,
BookOpen,
FileText,
GraduationCap,
Users,
ClipboardList,
} from 'lucide-vue-next'
import { inject, reactive, watch, ref, computed } from 'vue'
import { formatTime, showToast } from '@/utils'
import Rating from '@/components/Controls/Rating.vue'
import Link from '@/components/Controls/Link.vue'
const show = defineModel()
const dayjs = inject('$dayjs')
const tabIndex = ref(0)
const showCertification = ref(false)
const props = defineProps({
event: {
type: [Object, null],
required: true,
},
})
const evaluation = reactive({})
const certificate = reactive({})
const defaultTemplate = createResource({
url: 'frappe.client.get_value',
makeParams(values) {
return {
doctype: 'Property Setter',
fieldname: 'value',
filters: {
doc_type: 'LMS Certificate',
property: 'default_print_format',
},
}
},
auto: true,
onSuccess(data) {
certificate.template = data.value
},
})
const openCallLink = (link) => {
window.open(link, '_blank')
}
const evaluationResource = createResource({
url: 'lms.lms.api.save_evaluation_details',
makeParams(values) {
return {
member: props.event.member,
course: props.event.course,
batch_name: props.event.batch_name,
date: props.event.date,
start_time: props.event.start_time,
end_time: props.event.end_time,
status: evaluation.status,
rating: evaluation.rating,
summary: evaluation.summary,
evaluator: props.event.evaluator,
}
},
auto: false,
onSuccess(data) {
evaluation.name = data.name
},
})
const evaluationDetails = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Certificate Evaluation',
filters: {
member: props.event.member,
course: props.event.course,
},
}
},
onSuccess(data) {
for (const key in data) {
if (key in evaluation) evaluation[key] = data[key]
if (key == 'rating') evaluation.rating = data.rating * 5
if (evaluation.status == 'Pass') showCertification.value = true
}
},
auto: false,
})
const saveEvaluation = () => {
evaluationResource.submit(
{},
{
onSuccess: () => {
if (evaluation.status == 'Pass') {
showCertification.value = true
} else {
show.value = false
}
showToast(__('Success'), __('Evaluation saved successfully'), 'check')
},
}
)
}
const certificateResource = createResource({
url: 'lms.lms.api.save_certificate_details',
makeParams(values) {
return {
member: props.event.member,
course: props.event.course,
batch_name: props.event.batch_name,
published: certificate.published,
issue_date: certificate.issue_date,
expiry_date: certificate.expiry_date,
template: certificate.template,
evaluator: props.event.evaluator,
}
},
auto: false,
onSuccess(data) {
certificate.name = data
},
})
const certificateDetails = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Certificate',
filters: {
member: props.event.member,
course: props.event.course,
},
}
},
onSuccess(data) {
for (const key in data) {
if (key in certificate) certificate[key] = data[key]
certificate.name = data.name
showCertification.value = true
}
},
onError(err) {
certificate.template = defaultTemplate.data.value
},
auto: false,
})
const saveCertificate = () => {
certificateResource.submit(
{},
{
onSuccess: () => {
showToast(__('Success'), __('Certificate saved successfully'), 'check')
},
}
)
}
watch(show, () => {
if (show.value) {
evaluation.rating = 0
evaluation.status = 'Pending'
evaluation.summary = ''
evaluationDetails.reload()
certificate.published = true
certificate.issue_date = dayjs().format('YYYY-MM-DD')
certificate.expiry_date = null
certificate.template = null
certificate.name = null
certificateDetails.reload()
}
})
const openCertificate = (certificate) => {
window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
certificate.name
}&format=${encodeURIComponent(certificate.template)}`
)
}
const statusOptions = computed(() => {
return [
{
value: 'Pending',
label: __('Pending'),
},
{
value: 'In Progress',
label: __('In Progress'),
},
{
value: 'Pass',
label: __('Pass'),
},
{
value: 'Fail',
label: __('Fail'),
},
]
})
const tabs = computed(() => {
const tabsArray = [
{
label: __('Evaluation'),
icon: ClipboardList,
},
]
if (showCertification.value) {
tabsArray.push({
label: __('Certification'),
icon: GraduationCap,
})
}
return tabsArray
})
</script>

View File

@@ -0,0 +1,34 @@
<template>
<Dialog
v-model="show"
:options="{
size: '4xl',
}"
>
<template #body>
<div class="p-4">
<VideoBlock :file="file" />
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog } from 'frappe-ui'
import { computed } from 'vue'
import VideoBlock from '@/components/VideoBlock.vue'
const show = defineModel()
const props = defineProps({
type: {
type: [String, null],
required: true,
},
})
const file = computed(() => {
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
})
</script>

View File

@@ -212,7 +212,7 @@ const questionCreation = createResource({
}) })
const submitQuestion = (close) => { const submitQuestion = (close) => {
if (questionData.data?.name) updateQuestion(close) if (props.questionDetail?.question) updateQuestion(close)
else addQuestion(close) else addQuestion(close)
} }
@@ -239,7 +239,7 @@ const addQuestion = (close) => {
) )
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.message?.[0] || err), 'x') showToast(__('Error'), __(err.messages?.[0] || err), 'x')
}, },
} }
) )
@@ -259,7 +259,7 @@ const addQuestionRow = (question, close) => {
close() close()
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.message?.[0] || err), 'x') showToast(__('Error'), __(err.messages?.[0] || err), 'x')
close() close()
}, },
} }
@@ -312,13 +312,12 @@ const updateQuestion = (close) => {
quiz.value.reload() quiz.value.reload()
close() close()
}, },
onError(err) {
showToast(__('Error'), __(err.message?.[0] || err), 'x')
close()
},
} }
) )
}, },
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
} }
) )
} }

View File

@@ -6,7 +6,7 @@
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold"> <h1 class="mb-3 px-2 pt-2 text-lg font-semibold">
{{ __('Settings') }} {{ __('Settings') }}
</h1> </h1>
<div v-for="tab in tabs"> <div v-for="tab in tabs" :key="tab.label">
<div <div
v-if="!tab.hideLabel" v-if="!tab.hideLabel"
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-gray-600 transition-all duration-300 ease-in-out" class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-gray-600 transition-all duration-300 ease-in-out"
@@ -17,6 +17,7 @@
<SidebarLink <SidebarLink
v-for="item in tab.items" v-for="item in tab.items"
:link="item" :link="item"
:key="item.label"
class="w-full" class="w-full"
:class=" :class="
activeTab?.label == item.label activeTab?.label == item.label
@@ -30,7 +31,8 @@
</div> </div>
<div <div
v-if="activeTab && data.doc" v-if="activeTab && data.doc"
class="flex flex-1 flex-col px-10 pt-8" :key="activeTab.label"
class="flex flex-1 flex-col px-10 py-8"
> >
<Members <Members
v-if="activeTab.label === 'Members'" v-if="activeTab.label === 'Members'"
@@ -38,6 +40,11 @@
:description="activeTab.description" :description="activeTab.description"
v-model:show="show" v-model:show="show"
/> />
<Categories
v-else-if="activeTab.label === 'Categories'"
:label="activeTab.label"
:description="activeTab.description"
/>
<SettingDetails <SettingDetails
v-else v-else
:fields="activeTab.fields" :fields="activeTab.fields"
@@ -53,13 +60,16 @@
<script setup> <script setup>
import { Dialog, createDocumentResource } from 'frappe-ui' import { Dialog, createDocumentResource } from 'frappe-ui'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useSettings } from '@/stores/settings'
import SettingDetails from '../SettingDetails.vue' import SettingDetails from '../SettingDetails.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
import Members from '@/components/Members.vue' import Members from '@/components/Members.vue'
import Categories from '@/components/Categories.vue'
const show = defineModel() const show = defineModel()
const doctype = ref('LMS Settings') const doctype = ref('LMS Settings')
const activeTab = ref(null) const activeTab = ref(null)
const settingsStore = useSettings()
const data = createDocumentResource({ const data = createDocumentResource({
doctype: doctype.value, doctype: doctype.value,
@@ -69,8 +79,8 @@ const data = createDocumentResource({
auto: true, auto: true,
}) })
const tabs = computed(() => { const tabsStructure = computed(() => {
let _tabs = [ return [
{ {
label: 'Settings', label: 'Settings',
hideLabel: true, hideLabel: true,
@@ -80,6 +90,23 @@ const tabs = computed(() => {
description: 'Manage the members of your learning system', description: 'Manage the members of your learning system',
icon: 'UserRoundPlus', icon: 'UserRoundPlus',
}, },
],
},
{
label: 'Settings',
hideLabel: true,
items: [
{
label: 'Categories',
description: 'Manage the members of your learning system',
icon: 'Network',
},
],
},
{
label: 'Settings',
hideLabel: true,
items: [
{ {
label: 'Payment Gateway', label: 'Payment Gateway',
icon: 'DollarSign', icon: 'DollarSign',
@@ -125,8 +152,8 @@ const tabs = computed(() => {
], ],
}, },
{ {
label: 'Settings', label: 'Customise',
hideLabel: true, hideLabel: false,
items: [ items: [
{ {
label: 'Sidebar', label: 'Sidebar',
@@ -168,12 +195,6 @@ const tabs = computed(() => {
}, },
], ],
}, },
],
},
{
label: 'Settings',
hideLabel: true,
items: [
{ {
label: 'Email Templates', label: 'Email Templates',
icon: 'MailPlus', icon: 'MailPlus',
@@ -199,56 +220,19 @@ const tabs = computed(() => {
}, },
], ],
}, },
],
},
{
label: 'Settings',
hideLabel: true,
items: [
{ {
label: 'Signup', label: 'Signup',
icon: 'LogIn', icon: 'LogIn',
description:
'Customize the signup page to inform users about your terms and policies',
fields: [ fields: [
{ {
label: 'Show terms of use on signup', label: 'Custom Content',
name: 'terms_of_use', name: 'custom_signup_content',
type: 'checkbox', type: 'Code',
mode: 'htmlmixed',
rows: 10,
}, },
{ {
label: 'Terms of Use Page', label: 'Ask user category',
name: 'terms_page',
type: 'Link',
doctype: 'Web Page',
},
{
label: 'Show privacy policy on signup',
name: 'privacy_policy',
type: 'checkbox',
},
{
label: 'Privacy Policy Page',
name: 'privacy_policy_page',
type: 'Link',
doctype: 'Web Page',
},
{
type: 'Column Break',
},
{
label: 'Show cookie policy on signup',
name: 'cookie_policy',
type: 'checkbox',
},
{
label: 'Cookie Policy Page',
name: 'cookie_policy_page',
type: 'Link',
doctype: 'Web Page',
},
{
label: 'Ask user category during signup',
name: 'user_category', name: 'user_category',
type: 'checkbox', type: 'checkbox',
}, },
@@ -257,23 +241,28 @@ const tabs = computed(() => {
], ],
}, },
] ]
})
return _tabs.map((tab) => { const tabs = computed(() => {
tab.items = tab.items.filter((item) => { return tabsStructure.value.map((tab) => {
if (item.condition) { return {
return item.condition() ...tab,
items: tab.items.filter((item) => {
return !item.condition || item.condition()
}),
} }
return true
})
return tab
}) })
}) })
watch(show, () => { watch(show, async () => {
if (show.value) { if (show.value) {
activeTab.value = tabs.value[0].items[0] const currentTab = await tabs.value
.flatMap((tab) => tab.items)
.find((item) => item.label === settingsStore.activeTab)
activeTab.value = currentTab || tabs.value[0].items[0]
} else { } else {
activeTab.value = null activeTab.value = null
settingsStore.isSettingsOpen = false
} }
}) })
</script> </script>

View File

@@ -1,31 +0,0 @@
<template>
<Popover transition="default">
<template #target="{ isOpen, togglePopover }" class="flex w-full">
<slot v-bind="{ isOpen, togglePopover }"></slot>
</template>
<template #body>
<div
class="absolute left-0 mt-3 w-[35rem] max-w-lg -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
>
<div
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
>
<video
controls
autoplay
muted
width="100%"
controlsList="nodownload"
oncontextmenu="return false;"
class="rounded-sm"
>
<source src="/Youtube.mov" type="video/mp4" />
</video>
</div>
</div>
</template>
</Popover>
</template>
<script setup>
import { Popover } from 'frappe-ui'
</script>

View File

@@ -24,7 +24,7 @@
<div> <div>
{{ __('Please login to access this page.') }} {{ __('Please login to access this page.') }}
</div> </div>
<Button variant="solid" @click="redirectToLogin()" class="mt-2"> <Button @click="redirectToLogin()" class="mt-4">
{{ __('Login') }} {{ __('Login') }}
</Button> </Button>
</div> </div>

View File

@@ -84,7 +84,7 @@
</div> </div>
</div> </div>
<div <div
class="text-gray-900 font-semibold mt-2" class="text-gray-900 font-semibold mt-2 leading-5"
v-html="questionDetails.data.question" v-html="questionDetails.data.question"
></div> ></div>
<div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4"> <div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4">

View File

@@ -0,0 +1,58 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div class="p-5 space-y-4">
<div class="text-lg font-semibold">
{{ __('Add a quiz to your lesson') }}
</div>
<div>
<Link
v-model="quiz"
doctype="LMS Quiz"
:label="__('Select a quiz')"
:onCreate="(value, close) => redirectToQuizForm()"
/>
</div>
<div class="flex justify-end space-x-2">
<Button variant="solid" @click="addQuiz()">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog, Button } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const quiz = ref(null)
const props = defineProps({
onQuizAddition: {
type: Function,
required: true,
},
})
onMounted(async () => {
await nextTick()
show.value = true
})
const addQuiz = () => {
props.onQuizAddition(quiz.value)
show.value = false
}
const redirectToQuizForm = () => {
window.open('/lms/quizzes/new', '_blank')
}
</script>

View File

@@ -8,9 +8,15 @@
{{ __(description) }} {{ __(description) }}
</div> </div>
</div> </div>
<div class="flex justify-between my-5"> <div
class="my-5"
:class="{ 'flex justify-between w-full': columns.length > 1 }"
>
<div v-for="(column, index) in columns" :key="index"> <div v-for="(column, index) in columns" :key="index">
<div class="flex flex-col space-y-5 w-72"> <div
class="flex flex-col space-y-5"
:class="columns.length > 1 ? 'w-72' : 'w-full'"
>
<div v-for="field in column"> <div v-for="field in column">
<Link <Link
v-if="field.type == 'Link'" v-if="field.type == 'Link'"
@@ -18,12 +24,25 @@
:doctype="field.doctype" :doctype="field.doctype"
:label="field.label" :label="field.label"
/> />
<Codemirror
v-else-if="field.type == 'Code'"
v-model:value="field.value"
:label="field.label"
:height="200"
:options="{
mode: field.mode,
theme: 'seti',
}"
/>
<FormControl <FormControl
v-else v-else
:key="field.name" :key="field.name"
v-model="field.value" v-model="field.value"
:label="field.label" :label="field.label"
:type="field.type" :type="field.type"
:rows="field.rows"
/> />
</div> </div>
</div> </div>
@@ -41,6 +60,9 @@
import { FormControl, Button } from 'frappe-ui' import { FormControl, Button } from 'frappe-ui'
import { computed } from 'vue' import { computed } from 'vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import Codemirror from 'codemirror-editor-vue3'
import 'codemirror/theme/seti.css'
import 'codemirror/mode/htmlmixed/htmlmixed.js'
const props = defineProps({ const props = defineProps({
fields: { fields: {
@@ -94,3 +116,13 @@ const update = () => {
props.data.save.submit() props.data.save.submit()
} }
</script> </script>
<style>
.CodeMirror pre.CodeMirror-line,
.CodeMirror pre.CodeMirror-line-like {
font-family: revert;
}
.CodeMirror {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<FileUploader
:fileTypes="['image/*', 'video/*', 'audio/*', '.pdf']"
:validateFile="validateFile"
@success="(data) => addFile(data)"
ref="fileUploader"
class="hide"
/>
</template>
<script setup>
import { FileUploader } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
const fileUploader = ref(null)
const emit = defineEmits(['fileUploaded'])
const props = defineProps({
onFileUploaded: {
type: Function,
required: true,
},
})
onMounted(async () => {
await nextTick()
const fileInput = fileUploader.value.$el.querySelector('input[type="file"]')
if (fileInput) {
fileInput.click()
}
})
const addFile = (file) => {
props.onFileUploaded({
file_url: file.file_url,
file_type: file.file_type,
})
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3', 'pdf'].includes(extension)) {
return 'Only image and video files are allowed.'
}
}
const isVideo = (type) => {
return ['mov', 'mp4', 'avi', 'mkv', 'webm'].includes(type.toLowerCase())
}
const isAudio = (type) => {
return ['mp3', 'wav', 'ogg'].includes(type.toLowerCase())
}
</script>

View File

@@ -67,25 +67,20 @@ import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { Dropdown } from 'frappe-ui' import { Dropdown } from 'frappe-ui'
import Apps from '@/components/Apps.vue' import Apps from '@/components/Apps.vue'
import { import { ChevronDown, LogIn, LogOut, User, Settings } from 'lucide-vue-next'
ChevronDown,
LogIn,
LogOut,
User,
ArrowRightLeft,
Settings,
} from 'lucide-vue-next'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { convertToTitleCase } from '../utils' import { convertToTitleCase } from '../utils'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
import { ref, markRaw } from 'vue' import { useSettings } from '@/stores/settings'
import { markRaw, watch, ref } from 'vue'
import SettingsModal from '@/components/Modals/Settings.vue' import SettingsModal from '@/components/Modals/Settings.vue'
const router = useRouter() const router = useRouter()
const showSettingsModal = ref(false)
const { logout, branding } = sessionStore() const { logout, branding } = sessionStore()
let { userResource } = usersStore() let { userResource } = usersStore()
const settingsStore = useSettings()
let { isLoggedIn } = sessionStore() let { isLoggedIn } = sessionStore()
const showSettingsModal = ref(false)
const props = defineProps({ const props = defineProps({
isCollapsed: { isCollapsed: {
@@ -94,6 +89,13 @@ const props = defineProps({
}, },
}) })
watch(
() => settingsStore.isSettingsOpen,
(value) => {
showSettingsModal.value = value
}
)
const userDropdownOptions = [ const userDropdownOptions = [
{ {
icon: User, icon: User,
@@ -118,7 +120,7 @@ const userDropdownOptions = [
icon: Settings, icon: Settings,
label: 'Settings', label: 'Settings',
onClick: () => { onClick: () => {
showSettingsModal.value = true settingsStore.isSettingsOpen = true
}, },
condition: () => { condition: () => {
return userResource.data?.is_moderator return userResource.data?.is_moderator

View File

@@ -4,6 +4,7 @@
@timeupdate="updateTime" @timeupdate="updateTime"
@ended="videoEnded" @ended="videoEnded"
class="rounded-lg border border-gray-100" class="rounded-lg border border-gray-100"
ref="videoRef"
> >
<source :src="fileURL" :type="type" /> <source :src="fileURL" :type="type" />
</video> </video>
@@ -71,7 +72,6 @@ const props = defineProps({
onMounted(() => { onMounted(() => {
setTimeout(() => { setTimeout(() => {
videoRef.value = document.querySelector('video')
videoRef.value.onloadedmetadata = () => { videoRef.value.onloadedmetadata = () => {
duration.value = videoRef.value.duration duration.value = videoRef.value.duration
} }

View File

@@ -13,13 +13,9 @@
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Details') }} {{ __('Details') }}
</div> </div>
<div class="grid grid-cols-2 gap-10 mb-4"> <div class="grid grid-cols-2 gap-10 mb-4 space-y-2">
<div> <div>
<FormControl <FormControl v-model="batch.title" :label="__('Title')" />
v-model="batch.title"
:label="__('Title')"
class="mb-4"
/>
</div> </div>
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<FormControl <FormControl

View File

@@ -8,12 +8,12 @@
:items="[{ label: __('Batches'), route: { name: 'Batches' } }]" :items="[{ label: __('Batches'), route: { name: 'Batches' } }]"
/> />
<div class="flex space-x-2"> <div class="flex space-x-2">
<div class="w-40"> <div class="w-44">
<Select <Select
v-if="categories.data?.length" v-if="categories.data?.length"
v-model="currentCategory" v-model="currentCategory"
:options="categories.data" :options="categories.data"
:placeholder="__('Filter')" :placeholder="__('Category')"
/> />
</div> </div>
<router-link <router-link

View File

@@ -109,6 +109,14 @@
/> />
</div> </div>
</div> </div>
<div class="w-1/2 mb-4">
<Link
doctype="LMS Category"
v-model="course.category"
:label="__('Category')"
:onCreate="(value, close) => openSettings(close)"
/>
</div>
<MultiSelect <MultiSelect
v-model="instructors" v-model="instructors"
doctype="User" doctype="User"
@@ -221,18 +229,20 @@ import {
showToast, showToast,
getFileSize, getFileSize,
updateDocumentTitle, updateDocumentTitle,
} from '../utils' } from '@/utils'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { FileText, X } from 'lucide-vue-next' import { FileText, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import CourseOutline from '@/components/CourseOutline.vue' import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue' import MultiSelect from '@/components/Controls/MultiSelect.vue'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useSettings } from '@/stores/settings'
const user = inject('$user') const user = inject('$user')
const newTag = ref('') const newTag = ref('')
const router = useRouter() const router = useRouter()
const instructors = ref([]) const instructors = ref([])
const settingsStore = useSettings()
const props = defineProps({ const props = defineProps({
courseName: { courseName: {
@@ -420,7 +430,7 @@ const validateMandatoryFields = () => {
} }
} }
if (course.paid_course && (!course.course_price || !course.currency)) { if (course.paid_course && (!course.course_price || !course.currency)) {
return 'Course price and currency are mandatory for paid courses' return __('Course price and currency are mandatory for paid courses')
} }
} }
@@ -436,7 +446,7 @@ watch(
const validateFile = (file) => { const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase() let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) { if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
return 'Only image file is allowed.' return __('Only image file is allowed.')
} }
} }
@@ -463,6 +473,12 @@ const removeImage = () => {
course.course_image = null course.course_image = null
} }
const openSettings = (close) => {
close()
settingsStore.activeTab = 'Categories'
settingsStore.isSettingsOpen = true
}
const check_permission = () => { const check_permission = () => {
let user_is_instructor = false let user_is_instructor = false
if (user.data?.is_moderator) return if (user.data?.is_moderator) return

View File

@@ -8,6 +8,15 @@
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]" :items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
/> />
<div class="flex space-x-2 justify-end"> <div class="flex space-x-2 justify-end">
<div class="w-44">
<FormControl
v-if="categories.data?.length"
type="select"
v-model="currentCategory"
:options="categories.data"
:placeholder="__('Category')"
/>
</div>
<div class="w-36"> <div class="w-36">
<FormControl <FormControl
type="text" type="text"
@@ -119,11 +128,19 @@ import {
} from 'frappe-ui' } from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue' import CourseCard from '@/components/CourseCard.vue'
import { Plus, Search } from 'lucide-vue-next' import { Plus, Search } from 'lucide-vue-next'
import { ref, computed, inject } from 'vue' import { ref, computed, inject, onMounted, watch } from 'vue'
import { updateDocumentTitle } from '@/utils' import { updateDocumentTitle } from '@/utils'
const user = inject('$user') const user = inject('$user')
const searchQuery = ref('') const searchQuery = ref('')
const currentCategory = ref(null)
onMounted(() => {
let queries = new URLSearchParams(location.search)
if (queries.has('category')) {
currentCategory.value = queries.get('category')
}
})
const courses = createResource({ const courses = createResource({
url: 'lms.lms.utils.get_courses', url: 'lms.lms.utils.get_courses',
@@ -168,18 +185,57 @@ const addToTabs = (label) => {
} }
const getCourses = (type) => { const getCourses = (type) => {
let courseList = courses.data[type]
if (searchQuery.value) { if (searchQuery.value) {
let query = searchQuery.value.toLowerCase() let query = searchQuery.value.toLowerCase()
return courses.data[type].filter( courseList = courseList.filter(
(course) => (course) =>
course.title.toLowerCase().includes(query) || course.title.toLowerCase().includes(query) ||
course.short_introduction.toLowerCase().includes(query) || course.short_introduction.toLowerCase().includes(query) ||
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
) )
} }
return courses.data[type] if (currentCategory.value && currentCategory.value != '') {
courseList = courseList.filter(
(course) => course.category == currentCategory.value
)
}
return courseList
} }
const categories = createResource({
url: 'lms.lms.api.get_categories',
makeParams() {
return {
doctype: 'LMS Course',
filters: {
published: 1,
},
}
},
cache: ['courseCategories'],
auto: true,
transform(data) {
data.unshift({
label: '',
value: null,
})
},
})
watch(
() => currentCategory.value,
() => {
let queries = new URLSearchParams(location.search)
if (currentCategory.value) {
queries.set('category', currentCategory.value)
} else {
queries.delete('category')
}
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
}
)
const pageMeta = computed(() => { const pageMeta = computed(() => {
return { return {
title: 'Courses', title: 'Courses',

View File

@@ -120,7 +120,7 @@
</div> </div>
<div <div
v-if=" v-if="
lesson.data.instructor_content?.blocks?.length && JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
allowInstructorContent() allowInstructorContent()
" "
class="bg-gray-100 p-3 rounded-md mt-6" class="bg-gray-100 p-3 rounded-md mt-6"
@@ -244,7 +244,7 @@ const lesson = createResource({
onSuccess(data) { onSuccess(data) {
lessonProgress.value = data.membership?.progress lessonProgress.value = data.membership?.progress
if (data.content) editor.value = renderEditor('editor', data.content) if (data.content) editor.value = renderEditor('editor', data.content)
if (data.instructor_content?.blocks?.length) if (JSON.parse(data.instructor_content)?.blocks?.length > 1)
instructorEditor.value = renderEditor( instructorEditor.value = renderEditor(
'instructor-content', 'instructor-content',
data.instructor_content data.instructor_content
@@ -448,6 +448,10 @@ updateDocumentTitle(pageMeta)
max-width: unset; max-width: unset;
} }
.codex-editor__redactor {
padding-bottom: 0px !important;
}
.codeBoxHolder { .codeBoxHolder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -537,4 +541,13 @@ updateDocumentTitle(pageMeta)
color: #383a42; color: #383a42;
background-color: #fafafa; background-color: #fafafa;
} }
.codeBoxTextArea {
line-height: 1.7;
}
iframe {
border-top: 3px solid theme('colors.gray.700');
border-bottom: 3px solid theme('colors.gray.700');
}
</style> </style>

View File

@@ -62,7 +62,7 @@
</div> </div>
<div class=""> <div class="">
<div class="sticky top-0 p-5"> <div class="sticky top-0 p-5">
<LessonPlugins :editor="editor" :notesEditor="instructorEditor" /> <LessonHelp />
</div> </div>
</div> </div>
</div> </div>
@@ -79,7 +79,7 @@ import {
onBeforeUnmount, onBeforeUnmount,
} from 'vue' } from 'vue'
import EditorJS from '@editorjs/editorjs' import EditorJS from '@editorjs/editorjs'
import LessonPlugins from '@/components/LessonPlugins.vue' import LessonHelp from '@/components/LessonHelp.vue'
import { ChevronRight } from 'lucide-vue-next' import { ChevronRight } from 'lucide-vue-next'
import { updateDocumentTitle, createToast, getEditorTools } from '@/utils' import { updateDocumentTitle, createToast, getEditorTools } from '@/utils'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
@@ -117,7 +117,7 @@ onMounted(() => {
const renderEditor = (holder) => { const renderEditor = (holder) => {
return new EditorJS({ return new EditorJS({
holder: holder, holder: holder,
tools: getEditorTools(), tools: getEditorTools(true),
autofocus: true, autofocus: true,
}) })
} }
@@ -143,7 +143,9 @@ const lessonDetails = createResource({
Object.keys(data.lesson).forEach((key) => { Object.keys(data.lesson).forEach((key) => {
lesson[key] = data.lesson[key] lesson[key] = data.lesson[key]
}) })
lesson.include_in_preview = data.include_in_preview ? true : false lesson.include_in_preview = data?.lesson?.include_in_preview
? true
: false
addLessonContent(data) addLessonContent(data)
addInstructorNotes(data) addInstructorNotes(data)
enableAutoSave() enableAutoSave()
@@ -180,7 +182,7 @@ const addInstructorNotes = (data) => {
const enableAutoSave = () => { const enableAutoSave = () => {
autoSaveInterval = setInterval(() => { autoSaveInterval = setInterval(() => {
saveLesson() saveLesson()
}, 5000) }, 10000)
} }
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -423,7 +425,7 @@ const breadcrumbs = computed(() => {
}, },
{ {
label: lessonDetails.data?.course_title, label: lessonDetails.data?.course_title,
route: { name: 'CourseDetail', params: { courseName: props.courseName } }, route: { name: 'CourseForm', params: { courseName: props.courseName } },
}, },
] ]
@@ -473,6 +475,10 @@ updateDocumentTitle(pageMeta)
max-width: none; max-width: none;
} }
.codex-editor--narrow .ce-toolbar__actions {
right: 100%;
}
.ce-toolbar__content { .ce-toolbar__content {
max-width: none; max-width: none;
} }
@@ -545,10 +551,6 @@ updateDocumentTitle(pageMeta)
cursor: pointer; cursor: pointer;
} }
.codeBoxSelectItem:hover {
opacity: 0.7;
}
.codeBoxSelectedItem { .codeBoxSelectedItem {
background-color: lightblue !important; background-color: lightblue !important;
} }
@@ -566,4 +568,17 @@ updateDocumentTitle(pageMeta)
color: #383a42; color: #383a42;
background-color: #fafafa; background-color: #fafafa;
} }
.codeBoxTextArea {
line-height: 1.7;
}
.prose :where(pre):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
overflow-x: unset;
}
iframe {
border-top: 3px solid theme('colors.gray.700');
border-bottom: 3px solid theme('colors.gray.700');
}
</style> </style>

View File

@@ -146,7 +146,7 @@ const coverImage = createResource({
const setActiveTab = () => { const setActiveTab = () => {
let fragments = route.path.split('/') let fragments = route.path.split('/')
let sections = ['certificates', 'roles', 'evaluations'] let sections = ['certificates', 'roles', 'slots', 'schedule']
sections.forEach((section) => { sections.forEach((section) => {
if (fragments.includes(section)) { if (fragments.includes(section)) {
activeTab.value = convertToTitleCase(section) activeTab.value = convertToTitleCase(section)
@@ -161,7 +161,8 @@ watchEffect(() => {
About: { name: 'ProfileAbout' }, About: { name: 'ProfileAbout' },
Certificates: { name: 'ProfileCertificates' }, Certificates: { name: 'ProfileCertificates' },
Roles: { name: 'ProfileRoles' }, Roles: { name: 'ProfileRoles' },
Evaluations: { name: 'ProfileEvaluator' }, Slots: { name: 'ProfileEvaluator' },
Schedule: { name: 'ProfileEvaluationSchedule' },
}[activeTab.value] }[activeTab.value]
router.push(route) router.push(route)
} }
@@ -185,8 +186,13 @@ const isSessionUser = () => {
const getTabButtons = () => { const getTabButtons = () => {
let buttons = [{ label: 'About' }, { label: 'Certificates' }] let buttons = [{ label: 'About' }, { label: 'Certificates' }]
if ($user.data?.is_moderator) buttons.push({ label: 'Roles' }) if ($user.data?.is_moderator) buttons.push({ label: 'Roles' })
if (isSessionUser() && $user.data?.is_evaluator) if (
buttons.push({ label: 'Evaluations' }) isSessionUser() &&
($user.data?.is_evaluator || $user.data?.is_moderator)
) {
buttons.push({ label: 'Slots' })
buttons.push({ label: 'Schedule' })
}
return buttons return buttons
} }

View File

@@ -0,0 +1,102 @@
<template>
<div class="mt-7 mb-20">
<div class="flex h-screen flex-col overflow-hidden">
<Calendar
v-if="evaluations.data?.length"
:config="{
defaultMode: 'Month',
disableModes: ['Day', 'Week'],
redundantCellHeight: 100,
enableShortcuts: false,
}"
:events="evaluations.data"
@click="(event) => openEvent(event)"
>
<template #header="{ currentMonthYear, decrement, increment }">
<div class="mb-2 flex justify-between">
<span class="text-lg font-semibold">
{{ currentMonthYear }}
</span>
<div class="flex gap-x-1">
<Button
@click="decrement()"
variant="ghost"
class="h-4 w-4"
icon="chevron-left"
/>
<Button
@click="increment()"
variant="ghost"
class="h-4 w-4"
icon="chevron-right"
/>
</div>
</div>
</template>
</Calendar>
</div>
</div>
<Event v-model="showEvent" :event="currentEvent" />
</template>
<script setup>
import { Calendar, createListResource, Button } from 'frappe-ui'
import { inject, ref } from 'vue'
import Event from '@/components/Modals/Event.vue'
const user = inject('$user')
const currentEvent = ref(null)
const showEvent = ref(false)
const props = defineProps({
profile: {
type: Object,
required: true,
},
})
const evaluations = createListResource({
doctype: 'LMS Certificate Request',
filters: {
evaluator: user.data?.name,
},
fields: [
'name',
'member_name',
'member',
'course',
'course_title',
'batch_name',
'batch_title',
'evaluator',
'evaluator_name',
'date',
'start_time',
'end_time',
'google_meet_link',
],
auto: true,
orderBy: 'creation desc',
limit: 100,
cache: ['schedule', user.data?.name],
transform(data) {
return data.map((d) => {
let mappedData = Object.assign({}, d)
mappedData.title = `${d.member_name}'s Evaluation`
mappedData.participant = d.member_name
mappedData.id = d.name
mappedData.venue = d.google_meet_link
mappedData.fromDate = `${d.date} ${d.start_time}`
mappedData.toDate = `${d.date} ${d.end_time}`
mappedData.color = 'green'
return mappedData
})
},
})
const openEvent = (event) => {
currentEvent.value = event.calendarEvent
showEvent.value = true
}
</script>

View File

@@ -22,7 +22,7 @@
" "
/> />
<div v-if="quizDetails.data?.name"> <div v-if="quizDetails.data?.name">
<div class="grid grid-cols-3 gap-5 mt-2 mb-8"> <div class="grid grid-cols-3 gap-5 mt-4 mb-8">
<FormControl <FormControl
v-model="quiz.max_attempts" v-model="quiz.max_attempts"
:label="__('Maximun Attempts')" :label="__('Maximun Attempts')"
@@ -125,7 +125,7 @@
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button
variant="ghost" variant="ghost"
@click="deleteQuizzes(selections, unselectAll)" @click="deleteQuestions(selections, unselectAll)"
> >
<Trash2 class="h-4 w-4 stroke-1.5" /> <Trash2 class="h-4 w-4 stroke-1.5" />
</Button> </Button>
@@ -174,7 +174,7 @@ import {
} from 'vue' } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next' import { Plus, Trash2 } from 'lucide-vue-next'
import Question from '@/components/Modals/Question.vue' import Question from '@/components/Modals/Question.vue'
import { showToast } from '../utils' import { showToast, updateDocumentTitle } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const showQuestionModal = ref(false) const showQuestionModal = ref(false)
@@ -306,7 +306,7 @@ const createQuiz = () => {
onSuccess(data) { onSuccess(data) {
showToast(__('Success'), __('Quiz created successfully'), 'check') showToast(__('Success'), __('Quiz created successfully'), 'check')
router.push({ router.push({
name: 'QuizCreation', name: 'QuizForm',
params: { quizID: data.name }, params: { quizID: data.name },
}) })
}, },
@@ -375,24 +375,29 @@ const openQuestionModal = (question = null) => {
showQuestionModal.value = true showQuestionModal.value = true
} }
const deleteQuiz = createResource({ const deleteQuestionResource = createResource({
url: 'frappe.client.delete', url: 'lms.lms.api.delete_documents',
makeParams(values) { makeParams(values) {
return { return {
doctype: 'LMS Quiz Question', doctype: 'LMS Quiz Question',
name: values.quiz, documents: values.questions,
} }
}, },
}) })
const deleteQuizzes = (selections, unselectAll) => { const deleteQuestions = (selections, unselectAll) => {
selections.forEach(async (quiz) => { deleteQuestionResource.submit(
deleteQuiz.submit({ quiz }) {
}) questions: Array.from(selections),
setTimeout(() => { },
{
onSuccess() {
showToast(__('Success'), __('Questions deleted successfully'), 'check')
quizDetails.reload() quizDetails.reload()
unselectAll() unselectAll()
}, 500) },
}
)
} }
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
@@ -410,9 +415,18 @@ const breadcrumbs = computed(() => {
}) })
} */ } */
crumbs.push({ crumbs.push({
label: props.quizID == 'new' ? 'New Quiz' : quizDetails.data?.title, label: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
route: { name: 'QuizCreation', params: { quizID: props.quizID } }, route: { name: 'QuizForm', params: { quizID: props.quizID } },
}) })
return crumbs return crumbs
}) })
const pageMeta = computed(() => {
return {
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
description: __('Form to create and edit quizzes'),
}
})
updateDocumentTitle(pageMeta)
</script> </script>

View File

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

View File

@@ -5,7 +5,7 @@
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<router-link <router-link
:to="{ :to="{
name: 'QuizCreation', name: 'QuizForm',
params: { params: {
quizID: 'new', quizID: 'new',
}, },
@@ -36,7 +36,7 @@
<router-link <router-link
v-for="row in quizzes.data" v-for="row in quizzes.data"
:to="{ :to="{
name: 'QuizCreation', name: 'QuizForm',
params: { params: {
quizID: row.name, quizID: row.name,
}, },
@@ -62,6 +62,7 @@ import {
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { computed, inject, onMounted } from 'vue' import { computed, inject, onMounted } from 'vue'
import { Plus } from 'lucide-vue-next' import { Plus } from 'lucide-vue-next'
import { updateDocumentTitle } from '@/utils'
const user = inject('$user') const user = inject('$user')
const router = useRouter() const router = useRouter()
@@ -123,4 +124,13 @@ const breadcrumbs = computed(() => {
}, },
] ]
}) })
const pageMeta = computed(() => {
return {
title: __('Quizzes'),
description: __('List of quizzes'),
}
})
updateDocumentTitle(pageMeta)
</script> </script>

View File

@@ -79,9 +79,15 @@ const routes = [
}, },
{ {
name: 'ProfileEvaluator', name: 'ProfileEvaluator',
path: 'evaluations', path: 'slots',
component: () => import('@/pages/ProfileEvaluator.vue'), component: () => import('@/pages/ProfileEvaluator.vue'),
}, },
{
name: 'ProfileEvaluationSchedule',
path: 'schedule',
component: () =>
import('@/pages/ProfileEvaluationSchedule.vue'),
},
], ],
}, },
{ {
@@ -148,8 +154,14 @@ const routes = [
}, },
{ {
path: '/quizzes/:quizID', path: '/quizzes/:quizID',
name: 'QuizCreation', name: 'QuizForm',
component: () => import('@/pages/QuizCreation.vue'), component: () => import('@/pages/QuizForm.vue'),
props: true,
},
{
path: '/quiz/:quizID',
name: 'Quiz',
component: () => import('@/pages/QuizSubmission.vue'),
props: true, props: true,
}, },
] ]

View File

@@ -0,0 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useSettings = defineStore('settings', () => {
const isSettingsOpen = ref(false)
const activeTab = ref(null)
return {
isSettingsOpen,
activeTab,
}
})

View File

@@ -62,7 +62,7 @@ export class CodeBox {
static get toolbox() { static get toolbox() {
const app = createApp({ const app = createApp({
render: () => h(Code, { size: 24, strokeWidth: 2, color: 'black' }), render: () => h(Code, { size: 18, strokeWidth: 1.5, color: 'black' }),
}); });
const div = document.createElement('div'); const div = document.createElement('div');

View File

@@ -1,32 +0,0 @@
import Embed from '@editorjs/embed'
import VideoBlock from '@/components/VideoBlock.vue'
import { createApp } from 'vue'
export class CustomEmbed extends Embed {
render() {
const container = super.render()
const { service, source, embed } = this.data
if (service === 'youtube' || service === 'vimeo') {
// Remove the iframe or existing embed content
container.innerHTML = ''
// Create a placeholder element for Vue component
const vueContainer = document.createElement('div')
vueContainer.setAttribute('data-service', service)
vueContainer.setAttribute('data-video-id', this.data.source)
// Append the Vue placeholder
container.appendChild(vueContainer)
console.log(source)
// Mount the Vue component (using a global Vue instance)
const app = createApp(VideoBlock, {
file: source,
type: 'video/youtube',
})
app.mount(vueContainer)
}
return container
}
}

View File

@@ -149,9 +149,9 @@ export function getEditorTools() {
class: CodeBox, class: CodeBox,
config: { config: {
themeURL: themeURL:
'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/dracula.min.css', // Optional 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/atom-one-dark.min.css',
themeName: 'atom-one-dark', // Optional themeName: 'atom-one-dark',
useDefaultTheme: 'dark', // Optional. This also determines the background color of the language select drop-down useDefaultTheme: 'dark',
}, },
}, },
list: { list: {

View File

@@ -1,7 +1,9 @@
import QuizBlock from '@/components/QuizBlock.vue' import QuizBlock from '@/components/QuizBlock.vue'
import { createApp } from 'vue' import QuizPlugin from '@/components/QuizPlugin.vue'
import { createApp, h } from 'vue'
import { usersStore } from '../stores/user' import { usersStore } from '../stores/user'
import translationPlugin from '../translation' import translationPlugin from '../translation'
import { CircleHelp } from 'lucide-vue-next'
export class Quiz { export class Quiz {
constructor({ data, api, readOnly }) { constructor({ data, api, readOnly }) {
@@ -9,17 +11,31 @@ export class Quiz {
this.readOnly = readOnly this.readOnly = readOnly
} }
static get toolbox() {
const app = createApp({
render: () =>
h(CircleHelp, { size: 18, strokeWidth: 1.5, color: 'black' }),
})
const div = document.createElement('div')
app.mount(div)
return {
title: __('Quiz'),
icon: div.innerHTML,
}
}
static get isReadOnlySupported() { static get isReadOnlySupported() {
return true return true
} }
render() { render() {
this.wrapper = document.createElement('div') this.wrapper = document.createElement('div')
if (this.data) { if (Object.keys(this.data).length) {
let renderedQuiz = this.renderQuiz(this.data.quiz) this.renderQuiz(this.data.quiz)
if (!this.readOnly) { } else {
this.wrapper.innerHTML = renderedQuiz this.renderQuizModal()
}
} }
return this.wrapper return this.wrapper
} }
@@ -27,7 +43,7 @@ export class Quiz {
renderQuiz(quiz) { renderQuiz(quiz) {
if (this.readOnly) { if (this.readOnly) {
const app = createApp(QuizBlock, { const app = createApp(QuizBlock, {
quiz: quiz, // Pass quiz content as prop quiz: quiz,
}) })
app.use(translationPlugin) app.use(translationPlugin)
const { userResource } = usersStore() const { userResource } = usersStore()
@@ -35,11 +51,23 @@ export class Quiz {
app.mount(this.wrapper) app.mount(this.wrapper)
return return
} }
return `<div class='border rounded-md p-10 text-center mb-2'> this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center mb-2'>
<span class="font-medium"> <span class="font-medium">
Quiz: ${quiz} Quiz: ${quiz}
</span> </span>
</div>` </div>`
return
}
renderQuizModal() {
const app = createApp(QuizPlugin, {
onQuizAddition: (quiz) => {
this.data.quiz = quiz
this.renderQuiz(quiz)
},
})
app.use(translationPlugin)
app.mount(this.wrapper)
} }
save(blockContent) { save(blockContent) {

View File

@@ -1,6 +1,9 @@
import AudioBlock from '@/components/AudioBlock.vue' import AudioBlock from '@/components/AudioBlock.vue'
import VideoBlock from '@/components/VideoBlock.vue' import VideoBlock from '@/components/VideoBlock.vue'
import { createApp } from 'vue' import UploadPlugin from '@/components/UploadPlugin.vue'
import { h, createApp } from 'vue'
import { Upload as UploadIcon } from 'lucide-vue-next'
import translationPlugin from '../translation'
export class Upload { export class Upload {
constructor({ data, api, readOnly }) { constructor({ data, api, readOnly }) {
@@ -8,17 +11,38 @@ export class Upload {
this.readOnly = readOnly this.readOnly = readOnly
} }
static get toolbox() {
const app = createApp({
render: () =>
h(UploadIcon, { size: 18, strokeWidth: 1.5, color: 'black' }),
})
const div = document.createElement('div')
app.mount(div)
return {
title: 'Upload',
icon: div.innerHTML,
}
}
static get isReadOnlySupported() { static get isReadOnlySupported() {
return true return true
} }
render() { render() {
this.wrapper = document.createElement('div') this.wrapper = document.createElement('div')
this.renderUpload(this.data)
if (this.data && this.data.file_url) {
this.renderFile(this.data)
} else {
this.renderFileUploader()
}
return this.wrapper return this.wrapper
} }
renderUpload(file) { renderFile(file) {
if (this.isVideo(file.file_type)) { if (this.isVideo(file.file_type)) {
const app = createApp(VideoBlock, { const app = createApp(VideoBlock, {
file: file.file_url, file: file.file_url,
@@ -44,6 +68,25 @@ export class Upload {
} }
} }
renderFileUploader() {
const app = createApp(UploadPlugin, {
onFileUploaded: (file) => {
this.data.file_url = file.file_url
this.data.file_type = file.file_type
this.renderFile(file)
},
})
app.use(translationPlugin)
app.mount(this.wrapper)
}
validate(savedData) {
if (!savedData.file_url || !savedData.file_type) {
return false
}
return true
}
save(blockContent) { save(blockContent) {
return { return {
file_url: this.data.file_url, file_url: this.data.file_url,

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
[
{
"category": "Web Development",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:58:16.841571",
"name": "Web Development"
},
{
"category": "Business",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:58:32.304850",
"name": "Business"
},
{
"category": "Design",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:59:12.621022",
"name": "Design"
},
{
"category": "Personal Development",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:59:19.287404",
"name": "Personal Development"
},
{
"category": "Finance",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-09-20 12:58:28.579714",
"name": "Finance"
},
{
"category": "Frontend",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2024-05-08 14:05:16.979275",
"name": "Frontend"
},
{
"category": "Framework",
"docstatus": 0,
"doctype": "LMS Category",
"modified": "2023-06-15 18:01:41.598282",
"name": "Framework"
}
]

View File

@@ -115,7 +115,7 @@ scheduler_events = {
"daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"], "daily": ["lms.job.doctype.job_opportunity.job_opportunity.update_job_openings"],
} }
fixtures = ["Custom Field", "Function", "Industry"] fixtures = ["Custom Field", "Function", "Industry", "LMS Category"]
# Testing # Testing
# ------- # -------

View File

@@ -7,6 +7,7 @@ from frappe import _
from frappe.query_builder import DocType from frappe.query_builder import DocType
from frappe.query_builder.functions import Count from frappe.query_builder.functions import Count
from frappe.utils import time_diff, now_datetime, get_datetime from frappe.utils import time_diff, now_datetime, get_datetime
from typing import Optional
@frappe.whitelist() @frappe.whitelist()
@@ -610,3 +611,98 @@ def check_app_permission():
return True return True
return False return False
@frappe.whitelist()
def save_evaluation_details(
member,
course,
batch_name,
evaluator,
date,
start_time,
end_time,
status,
rating,
summary,
):
"""
Save evaluation details for a member against a course.
"""
evaluation = frappe.db.exists(
"LMS Certificate Evaluation", {"member": member, "course": course}
)
details = {
"date": date,
"start_time": start_time,
"end_time": end_time,
"status": status,
"rating": rating / 5,
"summary": summary,
"batch_name": batch_name,
}
if evaluation:
frappe.db.set_value("LMS Certificate Evaluation", evaluation, details)
return evaluation
else:
doc = frappe.new_doc("LMS Certificate Evaluation")
details.update(
{
"member": member,
"course": course,
"evaluator": evaluator,
}
)
doc.update(details)
doc.insert()
return doc.name
@frappe.whitelist()
def save_certificate_details(
member,
course,
batch_name,
evaluator,
issue_date,
expiry_date,
template,
published=True,
):
"""
Save certificate details for a member against a course.
"""
certificate = frappe.db.exists("LMS Certificate", {"member": member, "course": course})
details = {
"published": published,
"issue_date": issue_date,
"expiry_date": expiry_date,
"template": template,
"batch_name": batch_name,
}
if certificate:
frappe.db.set_value("LMS Certificate", certificate, details)
return certificate
else:
doc = frappe.new_doc("LMS Certificate")
details.update(
{
"member": member,
"course": course,
"evaluator": evaluator,
}
)
doc.update(details)
doc.insert()
return doc.name
@frappe.whitelist()
def delete_documents(doctype, documents):
frappe.only_for("Moderator")
for doc in documents:
frappe.delete_doc(doctype, doc)

View File

@@ -7,5 +7,4 @@ from frappe.utils.telemetry import capture
class CourseChapter(Document): class CourseChapter(Document):
def after_insert(self): pass
capture("chapter_created", "lms")

View File

@@ -24,9 +24,6 @@ class CourseLesson(Document):
for section in dynamic_documents: for section in dynamic_documents:
self.update_lesson_name_in_document(section) self.update_lesson_name_in_document(section)
def after_insert(self):
capture("lesson_created", "lms")
def update_lesson_name_in_document(self, section): def update_lesson_name_in_document(self, section):
doctype_map = {"Exercise": "LMS Exercise", "Quiz": "LMS Quiz"} doctype_map = {"Exercise": "LMS Exercise", "Quiz": "LMS Quiz"}
macros = find_macros(self.body) macros = find_macros(self.body)
@@ -116,6 +113,8 @@ def save_progress(lesson, course):
).save(ignore_permissions=True) ).save(ignore_permissions=True)
progress = get_course_progress(course) progress = get_course_progress(course)
capture_progress_for_analytics(progress, course)
# Had to get doc, as on_change doesn't trigger when you use set_value. The trigger is necesary for badge to get assigned. # Had to get doc, as on_change doesn't trigger when you use set_value. The trigger is necesary for badge to get assigned.
enrollment = frappe.get_doc("LMS Enrollment", membership) enrollment = frappe.get_doc("LMS Enrollment", membership)
enrollment.progress = progress enrollment.progress = progress
@@ -125,6 +124,11 @@ def save_progress(lesson, course):
return progress return progress
def capture_progress_for_analytics(progress, course):
if progress in [25, 50, 75, 100]:
capture("course_progress", "lms", {"course": course, "progress": progress})
def get_quiz_progress(lesson): def get_quiz_progress(lesson):
lesson_details = frappe.db.get_value( lesson_details = frappe.db.get_value(
"Course Lesson", lesson, ["body", "content"], as_dict=1 "Course Lesson", lesson, ["body", "content"], as_dict=1

View File

@@ -8,12 +8,7 @@ import json
from frappe import _ from frappe import _
from datetime import timedelta from datetime import timedelta
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import ( from frappe.utils import cint, format_date, format_datetime, get_time, getdate, add_days
cint,
format_date,
format_datetime,
get_time,
)
from lms.lms.utils import ( from lms.lms.utils import (
get_lessons, get_lessons,
get_lesson_index, get_lesson_index,
@@ -73,21 +68,23 @@ class LMSBatch(Document):
) )
) )
def validate_evaluation_end_date(self):
if self.evaluation_end_date and self.evaluation_end_date < self.end_date:
frappe.throw(_("Evaluation end date cannot be less than the batch end date."))
def send_confirmation_mail(self): def send_confirmation_mail(self):
for student in self.students: for student in self.students:
outgoing_email_account = frappe.get_cached_value( outgoing_email_account = frappe.get_cached_value(
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name" "Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
) )
if not student.confirmation_email_sent and ( if (
outgoing_email_account or frappe.conf.get("mail_login") not student.confirmation_email_sent
and getdate(student.creation) >= add_days(getdate(), -2)
and (outgoing_email_account or frappe.conf.get("mail_login"))
): ):
self.send_mail(student) self.send_mail(student)
student.confirmation_email_sent = 1 student.confirmation_email_sent = 1
def validate_evaluation_end_date(self):
if self.evaluation_end_date and self.evaluation_end_date < self.end_date:
frappe.throw(_("Evaluation end date cannot be less than the batch end date."))
def send_mail(self, student): def send_mail(self, student):
subject = _("Enrollment Confirmation for the Next Training Batch") subject = _("Enrollment Confirmation for the Next Training Batch")
template = "batch_confirmation" template = "batch_confirmation"

View File

@@ -15,12 +15,13 @@
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Category", "label": "Category",
"reqd": 1,
"unique": 1 "unique": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2023-06-15 15:14:11.341961", "modified": "2024-09-23 19:33:49.593950",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Category", "name": "LMS Category",
@@ -55,5 +56,6 @@
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"title_field": "category" "title_field": "category",
"track_changes": 1
} }

View File

@@ -15,8 +15,10 @@
"template", "template",
"published", "published",
"section_break_scyf", "section_break_scyf",
"expiry_date", "evaluator",
"evaluator_name",
"column_break_slaw", "column_break_slaw",
"expiry_date",
"batch_name" "batch_name"
], ],
"fields": [ "fields": [
@@ -95,11 +97,24 @@
{ {
"fieldname": "column_break_slaw", "fieldname": "column_break_slaw",
"fieldtype": "Column Break" "fieldtype": "Column Break"
},
{
"fieldname": "evaluator",
"fieldtype": "Link",
"label": "Evaluator",
"options": "User"
},
{
"fetch_from": "evaluator.full_name",
"fieldname": "evaluator_name",
"fieldtype": "Data",
"label": "Evaluator Name",
"read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-07-16 15:29:19.708888", "modified": "2024-09-11 11:37:20.419955",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Certificate", "name": "LMS Certificate",

View File

@@ -8,12 +8,16 @@
"field_order": [ "field_order": [
"member", "member",
"member_name", "member_name",
"column_break_ueht",
"course", "course",
"batch_name",
"section_break_zwfi",
"evaluator",
"evaluator_name",
"column_break_5", "column_break_5",
"date", "date",
"start_time", "start_time",
"end_time", "end_time",
"batch_name",
"section_break_6", "section_break_6",
"rating", "rating",
"status", "status",
@@ -103,11 +107,33 @@
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Batch Name", "label": "Batch Name",
"options": "LMS Batch" "options": "LMS Batch"
},
{
"fieldname": "column_break_ueht",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_zwfi",
"fieldtype": "Section Break"
},
{
"fieldname": "evaluator",
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Evaluator",
"options": "User"
},
{
"fetch_from": "evaluator.full_name",
"fieldname": "evaluator_name",
"fieldtype": "Data",
"label": "Evaluator Name",
"read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-07-16 14:06:11.977666", "modified": "2024-09-11 11:20:06.233491",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Certificate Evaluation", "name": "LMS Certificate Evaluation",

View File

@@ -16,6 +16,7 @@
"evaluator_name", "evaluator_name",
"column_break_sjco", "column_break_sjco",
"batch_name", "batch_name",
"batch_title",
"timezone", "timezone",
"section_break_lifi", "section_break_lifi",
"date", "date",
@@ -38,7 +39,6 @@
{ {
"fieldname": "member", "fieldname": "member",
"fieldtype": "Link", "fieldtype": "Link",
"in_standard_filter": 1,
"label": "Member", "label": "Member",
"options": "User", "options": "User",
"reqd": 1 "reqd": 1
@@ -46,9 +46,9 @@
{ {
"fieldname": "evaluator", "fieldname": "evaluator",
"fieldtype": "Link", "fieldtype": "Link",
"in_standard_filter": 1,
"label": "Evaluator", "label": "Evaluator",
"options": "User", "options": "User"
"read_only": 1
}, },
{ {
"fieldname": "date", "fieldname": "date",
@@ -137,11 +137,18 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Timezone", "label": "Timezone",
"read_only": 1 "read_only": 1
},
{
"fetch_from": "batch_name.title",
"fieldname": "batch_title",
"fieldtype": "Data",
"hidden": 1,
"label": "Batch Title"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-09-05 16:28:54.043488", "modified": "2024-09-11 11:19:44.669132",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Certificate Request", "name": "LMS Certificate Request",

View File

@@ -13,6 +13,7 @@ from frappe.utils import (
get_datetime, get_datetime,
nowtime, nowtime,
get_time, get_time,
get_fullname,
) )
from lms.lms.utils import get_evaluator from lms.lms.utils import get_evaluator
import json import json
@@ -32,6 +33,7 @@ class LMSCertificateRequest(Document):
def set_evaluator(self): def set_evaluator(self):
if not self.evaluator: if not self.evaluator:
self.evaluator = get_evaluator(self.course, self.batch_name) self.evaluator = get_evaluator(self.course, self.batch_name)
self.evaluator_name = get_fullname(self.evaluator)
def validate_unavailability(self): def validate_unavailability(self):
if self.evaluator: if self.evaluator:

View File

@@ -16,10 +16,12 @@
"field_order": [ "field_order": [
"title", "title",
"video_link", "video_link",
"image",
"column_break_3", "column_break_3",
"instructors", "instructors",
"tags", "tags",
"column_break_htgn",
"image",
"category",
"status", "status",
"section_break_7", "section_break_7",
"published", "published",
@@ -237,6 +239,16 @@
"fieldname": "certification_tab", "fieldname": "certification_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Certification" "label": "Certification"
},
{
"fieldname": "column_break_htgn",
"fieldtype": "Column Break"
},
{
"fieldname": "category",
"fieldtype": "Link",
"label": "Category",
"options": "LMS Category"
} }
], ],
"is_published_field": "published", "is_published_field": "published",
@@ -263,7 +275,7 @@
} }
], ],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2024-07-12 13:54:40.474097", "modified": "2024-09-21 10:23:58.633912",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Course", "name": "LMS Course",

View File

@@ -48,9 +48,6 @@ class LMSCourse(Document):
if not self.upcoming and self.has_value_changed("upcoming"): if not self.upcoming and self.has_value_changed("upcoming"):
self.send_email_to_interested_users() self.send_email_to_interested_users()
def after_insert(self):
capture("course_created", "lms")
def send_email_to_interested_users(self): def send_email_to_interested_users(self):
interested_users = frappe.get_all( interested_users = frappe.get_all(
"LMS Course Interest", {"course": self.name}, ["name", "user"] "LMS Course Interest", {"course": self.name}, ["name", "user"]

View File

@@ -23,15 +23,9 @@
"show_emails", "show_emails",
"signup_settings_tab", "signup_settings_tab",
"signup_settings_section", "signup_settings_section",
"terms_of_use",
"terms_page",
"user_category",
"column_break_9", "column_break_9",
"privacy_policy", "custom_signup_content",
"privacy_policy_page", "user_category",
"column_break_12",
"cookie_policy",
"cookie_policy_page",
"sidebar_tab", "sidebar_tab",
"items_in_sidebar_section", "items_in_sidebar_section",
"courses", "courses",
@@ -92,60 +86,14 @@
"fieldtype": "Column Break", "fieldtype": "Column Break",
"label": "Show Tab in Batch" "label": "Show Tab in Batch"
}, },
{
"default": "0",
"fieldname": "terms_of_use",
"fieldtype": "Check",
"label": "Show Terms of Use on Signup"
},
{
"depends_on": "terms_of_use",
"fieldname": "terms_page",
"fieldtype": "Link",
"label": "Terms of Use Page",
"mandatory_depends_on": "terms_of_use",
"options": "Web Page"
},
{ {
"fieldname": "signup_settings_section", "fieldname": "signup_settings_section",
"fieldtype": "Section Break" "fieldtype": "Section Break"
}, },
{
"default": "0",
"fieldname": "privacy_policy",
"fieldtype": "Check",
"label": "Show Privacy Policy on Signup"
},
{
"depends_on": "privacy_policy",
"fieldname": "privacy_policy_page",
"fieldtype": "Link",
"label": "Privacy Policy Page",
"mandatory_depends_on": "privacy_policy",
"options": "Web Page"
},
{
"fieldname": "column_break_12",
"fieldtype": "Column Break"
},
{ {
"fieldname": "column_break_9", "fieldname": "column_break_9",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"default": "0",
"fieldname": "cookie_policy",
"fieldtype": "Check",
"label": "Show Cookie Policy on Signup"
},
{
"depends_on": "cookie_policy",
"fieldname": "cookie_policy_page",
"fieldtype": "Link",
"label": "Cookie Policy Page",
"mandatory_depends_on": "cookie_policy",
"options": "Web Page"
},
{ {
"default": "0", "default": "0",
"fieldname": "user_category", "fieldname": "user_category",
@@ -378,12 +326,17 @@
"fieldtype": "Table", "fieldtype": "Table",
"label": "Sidebar Items", "label": "Sidebar Items",
"options": "LMS Sidebar Item" "options": "LMS Sidebar Item"
},
{
"fieldname": "custom_signup_content",
"fieldtype": "HTML Editor",
"label": "Custom Signup Content"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2024-08-13 19:02:58.714080", "modified": "2024-09-23 17:57:01.350020",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Settings", "name": "LMS Settings",

View File

@@ -722,17 +722,6 @@ def get_lesson_count(course):
return lesson_count return lesson_count
def get_restriction_details():
user = frappe.db.get_value(
"User", frappe.session.user, ["profile_complete", "username"], as_dict=True
)
return {
"restrict": not user.profile_complete,
"username": user.username,
"prefix": frappe.get_hooks("profile_url_prefix")[0] or "/users/",
}
def get_all_memberships(member): def get_all_memberships(member):
return frappe.get_all( return frappe.get_all(
"LMS Enrollment", "LMS Enrollment",
@@ -1220,6 +1209,7 @@ def get_course_details(course):
"featured", "featured",
"disable_self_learning", "disable_self_learning",
"published_on", "published_on",
"category",
"status", "status",
"paid_course", "paid_course",
"course_price", "course_price",

View File

@@ -7,8 +7,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Frappe LMS VERSION\n" "Project-Id-Version: Frappe LMS VERSION\n"
"Report-Msgid-Bugs-To: jannat@frappe.io\n" "Report-Msgid-Bugs-To: jannat@frappe.io\n"
"POT-Creation-Date: 2024-09-06 16:03+0000\n" "POT-Creation-Date: 2024-09-20 16:04+0000\n"
"PO-Revision-Date: 2024-09-06 16:03+0000\n" "PO-Revision-Date: 2024-09-20 16:04+0000\n"
"Last-Translator: jannat@frappe.io\n" "Last-Translator: jannat@frappe.io\n"
"Language-Team: jannat@frappe.io\n" "Language-Team: jannat@frappe.io\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
@@ -212,7 +212,7 @@ msgstr ""
msgid "Assessment Type" msgid "Assessment Type"
msgstr "" msgstr ""
#: lms/doctype/lms_batch/lms_batch.py:71 #: lms/doctype/lms_batch/lms_batch.py:66
msgid "Assessment {0} has already been added to this batch." msgid "Assessment {0} has already been added to this batch."
msgstr "" msgstr ""
@@ -378,11 +378,16 @@ msgstr ""
msgid "Batch Student" msgid "Batch Student"
msgstr "" msgstr ""
#. Label of the batch_title (Data) field in DocType 'LMS Certificate Request'
#: lms/doctype/lms_certificate_request/lms_certificate_request.json
msgid "Batch Title"
msgstr ""
#: public/js/common_functions.js:427 #: public/js/common_functions.js:427
msgid "Batch Updated" msgid "Batch Updated"
msgstr "" msgstr ""
#: lms/doctype/lms_batch/lms_batch.py:42 #: lms/doctype/lms_batch/lms_batch.py:37
msgid "Batch end date cannot be before the batch start date" msgid "Batch end date cannot be before the batch start date"
msgstr "" msgstr ""
@@ -393,7 +398,7 @@ msgstr ""
#. Group in LMS Course's connections #. Group in LMS Course's connections
#. Label of the batches (Check) field in DocType 'LMS Settings' #. Label of the batches (Check) field in DocType 'LMS Settings'
#: lms/doctype/lms_course/lms_course.json #: lms/doctype/lms_course/lms_course.json
#: lms/doctype/lms_settings/lms_settings.json www/lms.py:61 #: lms/doctype/lms_settings/lms_settings.json www/lms.py:60
msgid "Batches" msgid "Batches"
msgstr "" msgstr ""
@@ -906,7 +911,7 @@ msgstr ""
msgid "Course Lesson" msgid "Course Lesson"
msgstr "" msgstr ""
#: www/lms.py:28 #: www/lms.py:27
msgid "Course List" msgid "Course List"
msgstr "" msgstr ""
@@ -943,11 +948,11 @@ msgstr ""
msgid "Course Title" msgid "Course Title"
msgstr "" msgstr ""
#: lms/doctype/lms_batch/lms_batch.py:327 #: lms/doctype/lms_batch/lms_batch.py:324
msgid "Course already added to the batch." msgid "Course already added to the batch."
msgstr "" msgstr ""
#: lms/doctype/lms_batch/lms_batch.py:60 #: lms/doctype/lms_batch/lms_batch.py:55
msgid "Course {0} has already been added to this batch." msgid "Course {0} has already been added to this batch."
msgstr "" msgstr ""
@@ -1243,7 +1248,7 @@ msgstr ""
msgid "Enrolled successfully" msgid "Enrolled successfully"
msgstr "" msgstr ""
#: lms/doctype/lms_batch/lms_batch.py:92 #: lms/doctype/lms_batch/lms_batch.py:89
msgid "Enrollment Confirmation for the Next Training Batch" msgid "Enrollment Confirmation for the Next Training Batch"
msgstr "" msgstr ""
@@ -1292,24 +1297,33 @@ msgstr ""
msgid "Evaluation Request" msgid "Evaluation Request"
msgstr "" msgstr ""
#: lms/doctype/lms_batch/lms_batch.py:89 #: lms/doctype/lms_batch/lms_batch.py:73
msgid "Evaluation end date cannot be less than the batch end date." msgid "Evaluation end date cannot be less than the batch end date."
msgstr "" msgstr ""
#. Label of the evaluator (Link) field in DocType 'Batch Course' #. Label of the evaluator (Link) field in DocType 'Batch Course'
#. Label of the evaluator (Link) field in DocType 'Course Evaluator' #. Label of the evaluator (Link) field in DocType 'Course Evaluator'
#. Label of the evaluator (Link) field in DocType 'LMS Assignment Submission' #. Label of the evaluator (Link) field in DocType 'LMS Assignment Submission'
#. Label of the evaluator (Link) field in DocType 'LMS Certificate'
#. Label of the evaluator (Link) field in DocType 'LMS Certificate Evaluation'
#. Label of the evaluator (Link) field in DocType 'LMS Certificate Request' #. Label of the evaluator (Link) field in DocType 'LMS Certificate Request'
#: lms/doctype/batch_course/batch_course.json #: lms/doctype/batch_course/batch_course.json
#: lms/doctype/course_evaluator/course_evaluator.json #: lms/doctype/course_evaluator/course_evaluator.json
#: lms/doctype/lms_assignment_submission/lms_assignment_submission.json #: lms/doctype/lms_assignment_submission/lms_assignment_submission.json
#: lms/doctype/lms_certificate/lms_certificate.json
#: lms/doctype/lms_certificate_evaluation/lms_certificate_evaluation.json
#: lms/doctype/lms_certificate_request/lms_certificate_request.json #: lms/doctype/lms_certificate_request/lms_certificate_request.json
#: templates/upcoming_evals.html:33 #: templates/upcoming_evals.html:33
msgid "Evaluator" msgid "Evaluator"
msgstr "" msgstr ""
#. Label of the evaluator_name (Data) field in DocType 'LMS Certificate'
#. Label of the evaluator_name (Data) field in DocType 'LMS Certificate
#. Evaluation'
#. Label of the evaluator_name (Data) field in DocType 'LMS Certificate #. Label of the evaluator_name (Data) field in DocType 'LMS Certificate
#. Request' #. Request'
#: lms/doctype/lms_certificate/lms_certificate.json
#: lms/doctype/lms_certificate_evaluation/lms_certificate_evaluation.json
#: lms/doctype/lms_certificate_request/lms_certificate_request.json #: lms/doctype/lms_certificate_request/lms_certificate_request.json
msgid "Evaluator Name" msgid "Evaluator Name"
msgstr "" msgstr ""
@@ -1841,7 +1855,7 @@ msgstr ""
msgid "Job Board Title" msgid "Job Board Title"
msgstr "" msgstr ""
#: www/lms.py:109 #: www/lms.py:108
msgid "Job Openings" msgid "Job Openings"
msgstr "" msgstr ""
@@ -2403,11 +2417,11 @@ msgstr ""
msgid "Modified By" msgid "Modified By"
msgstr "" msgstr ""
#: lms/api.py:189 #: lms/api.py:190
msgid "Module Name is incorrect or does not exist." msgid "Module Name is incorrect or does not exist."
msgstr "" msgstr ""
#: lms/api.py:185 #: lms/api.py:186
msgid "Module is incorrect." msgid "Module is incorrect."
msgstr "" msgstr ""
@@ -2432,11 +2446,11 @@ msgstr ""
msgid "New Assignment Submission" msgid "New Assignment Submission"
msgstr "" msgstr ""
#: public/js/common_functions.js:255 www/lms.py:87 #: public/js/common_functions.js:255 www/lms.py:86
msgid "New Batch" msgid "New Batch"
msgstr "" msgstr ""
#: www/lms.py:38 #: www/lms.py:37
msgid "New Course" msgid "New Course"
msgstr "" msgstr ""
@@ -2774,7 +2788,7 @@ msgstr ""
msgid "Please click on the following button to set your new password" msgid "Please click on the following button to set your new password"
msgstr "" msgstr ""
#: lms/doctype/lms_batch/lms_batch.py:238 #: lms/doctype/lms_batch/lms_batch.py:235
msgid "Please enable Zoom Settings to use this feature." msgid "Please enable Zoom Settings to use this feature."
msgstr "" msgstr ""
@@ -2791,7 +2805,7 @@ msgstr ""
msgid "Please enter your answer" msgid "Please enter your answer"
msgstr "" msgstr ""
#: lms/api.py:181 #: lms/api.py:182
msgid "Please login to continue with payment." msgid "Please login to continue with payment."
msgstr "" msgstr ""
@@ -3151,19 +3165,19 @@ msgstr ""
msgid "Route" msgid "Route"
msgstr "" msgstr ""
#: lms/doctype/lms_batch/lms_batch.py:167 #: lms/doctype/lms_batch/lms_batch.py:164
msgid "Row #{0} Date cannot be outside the batch duration." msgid "Row #{0} Date cannot be outside the batch duration."
msgstr "" msgstr ""
#: lms/doctype/lms_batch/lms_batch.py:162 #: lms/doctype/lms_batch/lms_batch.py:159
msgid "Row #{0} End time cannot be outside the batch duration." msgid "Row #{0} End time cannot be outside the batch duration."
msgstr "" msgstr ""
#: lms/doctype/lms_batch/lms_batch.py:144 #: lms/doctype/lms_batch/lms_batch.py:141
msgid "Row #{0} Start time cannot be greater than or equal to end time." msgid "Row #{0} Start time cannot be greater than or equal to end time."
msgstr "" msgstr ""
#: lms/doctype/lms_batch/lms_batch.py:153 #: lms/doctype/lms_batch/lms_batch.py:150
msgid "Row #{0} Start time cannot be outside the batch duration." msgid "Row #{0} Start time cannot be outside the batch duration."
msgstr "" msgstr ""
@@ -3454,7 +3468,7 @@ msgid "Startup Organization"
msgstr "" msgstr ""
#. Label of the statistics (Check) field in DocType 'LMS Settings' #. Label of the statistics (Check) field in DocType 'LMS Settings'
#: lms/doctype/lms_settings/lms_settings.json www/lms.py:134 #: lms/doctype/lms_settings/lms_settings.json www/lms.py:133
msgid "Statistics" msgid "Statistics"
msgstr "" msgstr ""
@@ -3510,7 +3524,7 @@ msgstr ""
msgid "Student Name" msgid "Student Name"
msgstr "" msgstr ""
#: lms/doctype/lms_batch/lms_batch.py:49 #: lms/doctype/lms_batch/lms_batch.py:44
msgid "Student {0} has already been added to this batch." msgid "Student {0} has already been added to this batch."
msgstr "" msgstr ""
@@ -3683,7 +3697,7 @@ msgstr ""
msgid "The course {0} is now available on {1}." msgid "The course {0} is now available on {1}."
msgstr "" msgstr ""
#: lms/doctype/lms_certificate_request/lms_certificate_request.py:51 #: lms/doctype/lms_certificate_request/lms_certificate_request.py:53
msgid "The evaluator of this course is unavailable from {0} to {1}. Please select a date after {1}" msgid "The evaluator of this course is unavailable from {0} to {1}. Please select a date after {1}"
msgstr "" msgstr ""
@@ -3691,7 +3705,7 @@ msgstr ""
msgid "The quiz has a time limit. For each question you will be given {0} seconds." msgid "The quiz has a time limit. For each question you will be given {0} seconds."
msgstr "" msgstr ""
#: lms/doctype/lms_certificate_request/lms_certificate_request.py:69 #: lms/doctype/lms_certificate_request/lms_certificate_request.py:71
msgid "The slot is already booked by another participant." msgid "The slot is already booked by another participant."
msgstr "" msgstr ""
@@ -3699,7 +3713,7 @@ msgstr ""
msgid "The status of your application has changed." msgid "The status of your application has changed."
msgstr "" msgstr ""
#: lms/doctype/lms_batch/lms_batch.py:135 #: lms/doctype/lms_batch/lms_batch.py:132
msgid "There are no seats available in this batch." msgid "There are no seats available in this batch."
msgstr "" msgstr ""
@@ -4092,15 +4106,15 @@ msgstr ""
msgid "Write a review" msgid "Write a review"
msgstr "" msgstr ""
#: lms/doctype/lms_certificate_request/lms_certificate_request.py:93 #: lms/doctype/lms_certificate_request/lms_certificate_request.py:95
msgid "You already have an evaluation on {0} at {1} for the course {2}." msgid "You already have an evaluation on {0} at {1} for the course {2}."
msgstr "" msgstr ""
#: lms/api.py:205 #: lms/api.py:206
msgid "You are already enrolled for this batch." msgid "You are already enrolled for this batch."
msgstr "" msgstr ""
#: lms/api.py:197 #: lms/api.py:198
msgid "You are already enrolled for this course." msgid "You are already enrolled for this course."
msgstr "" msgstr ""
@@ -4125,11 +4139,11 @@ msgstr ""
msgid "You can find their resume attached to this email." msgid "You can find their resume attached to this email."
msgstr "" msgstr ""
#: lms/doctype/lms_certificate_request/lms_certificate_request.py:113 #: lms/doctype/lms_certificate_request/lms_certificate_request.py:115
msgid "You cannot schedule evaluations after {0}." msgid "You cannot schedule evaluations after {0}."
msgstr "" msgstr ""
#: lms/doctype/lms_certificate_request/lms_certificate_request.py:102 #: lms/doctype/lms_certificate_request/lms_certificate_request.py:104
msgid "You cannot schedule evaluations for past slots." msgid "You cannot schedule evaluations for past slots."
msgstr "" msgstr ""
@@ -4194,7 +4208,7 @@ msgstr ""
msgid "Your evaluation for the course {0} has been scheduled on {1} at {2} {3}." msgid "Your evaluation for the course {0} has been scheduled on {1} at {2} {3}."
msgstr "" msgstr ""
#: lms/doctype/lms_certificate_request/lms_certificate_request.py:123 #: lms/doctype/lms_certificate_request/lms_certificate_request.py:125
msgid "Your evaluation slot has been booked" msgid "Your evaluation slot has been booked"
msgstr "" msgstr ""

View File

@@ -90,4 +90,4 @@ lms.patches.v1_0.set_published_on
lms.patches.v2_0.fix_progress_percentage lms.patches.v2_0.fix_progress_percentage
lms.patches.v2_0.add_discussion_topic_titles lms.patches.v2_0.add_discussion_topic_titles
lms.patches.v2_0.sidebar_settings lms.patches.v2_0.sidebar_settings
lms.patches.v2_0.delete_certificate_request_notification lms.patches.v2_0.delete_certificate_request_notification #18-09-2024

View File

@@ -2,4 +2,10 @@ import frappe
def execute(): def execute():
frappe.db.delete("Notification", "Certificate Request Creation") delete_notification("Certificate Request Creation")
delete_notification("Certificate Request Reminder")
def delete_notification(notification_name):
if frappe.db.exists("Notification", notification_name):
frappe.db.delete("Notification", notification_name)

View File

@@ -227,8 +227,7 @@ def assignment_renderer(detail):
def show_custom_signup(): def show_custom_signup():
if frappe.db.get_single_value( settings = frappe.get_single("LMS Settings")
"LMS Settings", "terms_of_use" if settings.custom_signup_content or settings.user_category:
) or frappe.db.get_single_value("LMS Settings", "privacy_policy"):
return "lms/templates/signup-form.html" return "lms/templates/signup-form.html"
return "frappe/templates/signup.html" return "frappe/templates/signup.html"

View File

@@ -2202,7 +2202,7 @@ select {
.rows .grid-row .data-row, .rows .grid-row .data-row,
.rows .grid-row .grid-footer-toolbar, .rows .grid-row .grid-footer-toolbar,
.grid-form-heading { .grid-form-heading {
cursor: none; cursor: pointer;
} }
.schedule-header { .schedule-header {

View File

@@ -1,3 +1,4 @@
{% set custom_signup_content = frappe.db.get_single_value("LMS Settings", "custom_signup_content") %}
<form class="signup-form" role="form"> <form class="signup-form" role="form">
<div class="page-card-body"> <div class="page-card-body">
<div class="form-group"> <div class="form-group">
@@ -31,6 +32,7 @@
</div> </div>
{% endif %} {% endif %}
{% if custom_signup_content %}
<div class="form-group"> <div class="form-group">
<div class="checkbox"> <div class="checkbox">
<label> <label>
@@ -39,11 +41,12 @@
data-fieldtype="Check" data-fieldname="terms" id="signup-terms" required> data-fieldtype="Check" data-fieldname="terms" id="signup-terms" required>
</span> </span>
<span class="label-area"> <span class="label-area">
{{ _("I have read and agree to your {0}").format(get_signup_optin_checks()) }} {{ custom_signup_content }}
</span> </span>
</label> </label>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
<div class="page-card-actions"> <div class="page-card-actions">
<button class="btn btn-sm btn-primary btn-block btn-signup" <button class="btn btn-sm btn-primary btn-block btn-signup"

View File

@@ -17,7 +17,6 @@ def get_context():
csrf_token = frappe.sessions.get_csrf_token() csrf_token = frappe.sessions.get_csrf_token()
frappe.db.commit() # nosemgrep frappe.db.commit() # nosemgrep
context.csrf_token = csrf_token context.csrf_token = csrf_token
if frappe.session.user != "Guest":
capture("active_site", "lms") capture("active_site", "lms")
return context return context
@@ -149,6 +148,7 @@ def get_meta(app_path):
as_dict=True, as_dict=True,
) )
if user.bio:
soup = BeautifulSoup(user.bio, "html.parser") soup = BeautifulSoup(user.bio, "html.parser")
user.bio = soup.get_text() user.bio = soup.get_text()

3948
yarn.lock

File diff suppressed because it is too large Load Diff