Merge branch 'develop' into patch-2
This commit is contained in:
2
.github/workflows/linters.yml
vendored
2
.github/workflows/linters.yml
vendored
@@ -30,4 +30,4 @@ jobs:
|
||||
run: pip install semgrep
|
||||
|
||||
- name: Run Semgrep rules
|
||||
run: semgrep ci --config ./frappe-semgrep-rules/rules
|
||||
run: semgrep ci --config ./frappe-semgrep-rules/rules
|
||||
@@ -1,133 +1,150 @@
|
||||
describe("Course Creation", () => {
|
||||
it("creates a new course", () => {
|
||||
cy.login();
|
||||
cy.visit("/courses");
|
||||
cy.wait(1000);
|
||||
|
||||
cy.visit("/lms/courses");
|
||||
|
||||
// Create a course
|
||||
cy.get("a.btn").contains("Create a Course").click();
|
||||
cy.wait(1000);
|
||||
cy.url().should("include", "/courses/new-course/edit");
|
||||
cy.get("#title").type("Test Course");
|
||||
cy.get("#intro").type("Test Course Short Introduction");
|
||||
cy.get("#description").type("Test Course Description");
|
||||
cy.get("#video-link").type("-LPmw2Znl2c");
|
||||
cy.get("#tags-input").type("Test");
|
||||
cy.get("#published").check();
|
||||
cy.get("a").contains("New Course").click();
|
||||
cy.wait(1000);
|
||||
cy.url().should("include", "/courses/new/edit");
|
||||
|
||||
cy.get("label").contains("Title").type("Test Course");
|
||||
cy.get("label")
|
||||
.contains("Short Introduction")
|
||||
.type("Test Course Short Introduction to test the UI");
|
||||
cy.get("div[contenteditable=true").invoke(
|
||||
"text",
|
||||
"Test Course Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
|
||||
cy.fixture("profile.png", "base64").then((fileContent) => {
|
||||
cy.get('input[type="file"]').attachFile({
|
||||
fileContent,
|
||||
fileName: "profile.png",
|
||||
mimeType: "image/png",
|
||||
encoding: "base64",
|
||||
});
|
||||
});
|
||||
|
||||
cy.get("label")
|
||||
.contains("Preview Video")
|
||||
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
|
||||
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
|
||||
cy.get("label").contains("Published").click();
|
||||
cy.get("label").contains("Published On").type("2021-01-01");
|
||||
cy.button("Save").click();
|
||||
|
||||
// Add Chapter
|
||||
cy.wait(1000);
|
||||
cy.link("Course Outline").click();
|
||||
cy.button("Add Chapter").click();
|
||||
|
||||
cy.wait(1000);
|
||||
cy.get(".edit-header .btn-add-chapter").click();
|
||||
cy.wait(500);
|
||||
cy.get("#chapter-title").type("Test Chapter");
|
||||
cy.get("#chapter-description").type("Test Chapter Description");
|
||||
cy.button("Save").click();
|
||||
cy.get("[id^=headlessui-dialog-panel-")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.get("label").contains("Title").type("Test Chapter");
|
||||
cy.button("Add Chapter").click();
|
||||
});
|
||||
|
||||
// Add Lesson
|
||||
cy.wait(1000);
|
||||
cy.link("Add Lesson").click();
|
||||
cy.button("Add Lesson").click();
|
||||
cy.wait(1000);
|
||||
cy.url().should("include", "/learn/1-1/edit");
|
||||
cy.wait(1000);
|
||||
cy.get("#lesson-title").type("Test Lesson");
|
||||
|
||||
// Content
|
||||
cy.get(".collapse-section.collapsed:first").click();
|
||||
cy.get("#lesson-content .ce-block")
|
||||
cy.get("label").contains("Title").type("Test Lesson");
|
||||
/* cy.get("#content .ce-block")
|
||||
.click()
|
||||
.type(
|
||||
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now. {enter}"
|
||||
);
|
||||
cy.get("#lesson-content .ce-toolbar__plus").click();
|
||||
cy.get('#lesson-content [data-item-name="youtube"]').click();
|
||||
cy.get('input[data-fieldname="youtube"]').type("GoDtyItReto");
|
||||
cy.button("Insert").click();
|
||||
cy.wait(1000);
|
||||
.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(
|
||||
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
cy.button("Save").click();
|
||||
|
||||
// View Course
|
||||
cy.wait(1000);
|
||||
cy.visit("/courses");
|
||||
cy.get(".course-card-title:first").contains("Test Course");
|
||||
cy.get(".course-card:first").click();
|
||||
cy.url().should("include", "/courses/test-course");
|
||||
cy.get("#title").contains("Test Course");
|
||||
cy.get(".preview-video").should(
|
||||
cy.visit("/lms");
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/lms/courses");
|
||||
cy.get(".grid a:first").within(() => {
|
||||
cy.get("div").contains("Test Course");
|
||||
cy.get("div").contains(
|
||||
"Test Course Short Introduction to test the UI"
|
||||
);
|
||||
cy.get(".course-image")
|
||||
.invoke("css", "background-image")
|
||||
.should("include", "/files/profile");
|
||||
});
|
||||
cy.get(".grid a:first").click();
|
||||
cy.url().should("include", "/lms/courses/test-course");
|
||||
cy.get("div").contains("Test Course");
|
||||
cy.get("div").contains("Test Course Short Introduction to test the UI");
|
||||
cy.get("div").contains("Learning");
|
||||
cy.get("div").contains("Frappe");
|
||||
cy.get("div").contains("ERPNext");
|
||||
cy.get("iframe").should(
|
||||
"have.attr",
|
||||
"src",
|
||||
"https://www.youtube.com/embed/-LPmw2Znl2c"
|
||||
);
|
||||
cy.get("#intro").contains("Test Course Short Introduction");
|
||||
|
||||
// View Chapter
|
||||
cy.get(".chapter-title-main:first").contains("Test Chapter");
|
||||
cy.get(".chapter-description:first").contains(
|
||||
"Test Chapter Description"
|
||||
);
|
||||
cy.get(".lesson-info:first").contains("Test Lesson");
|
||||
cy.get(".lesson-info:first").click();
|
||||
cy.get("div").contains("Test Chapter");
|
||||
cy.get("[id^=headlessui-disclosure-panel-").within(() => {
|
||||
cy.get("div").contains("Test Lesson").click();
|
||||
});
|
||||
cy.wait(1000);
|
||||
|
||||
// View Lesson
|
||||
cy.wait(1000);
|
||||
cy.url().should("include", "learn/1.1");
|
||||
cy.get("#title").contains("Test Lesson");
|
||||
cy.get(".lesson-video iframe").should(
|
||||
"have.attr",
|
||||
"src",
|
||||
"https://www.youtube.com/embed/GoDtyItReto"
|
||||
);
|
||||
cy.get(".lesson-content-card").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."
|
||||
cy.url().should("include", "/learn/1-1");
|
||||
cy.get("div").contains("Test Lesson");
|
||||
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. "
|
||||
);
|
||||
|
||||
cy.get("video")
|
||||
.should("be.visible")
|
||||
.children("source")
|
||||
.invoke("attr", "src")
|
||||
.should("include", "/files/Youtube");
|
||||
|
||||
// Add Discussion
|
||||
cy.get(".reply").click();
|
||||
cy.button("New Question").click();
|
||||
cy.wait(500);
|
||||
cy.get(".discussion-modal").should("be.visible");
|
||||
|
||||
// Enter title
|
||||
cy.get(".modal .topic-title")
|
||||
.type("Discussion from tests")
|
||||
.should("have.value", "Discussion from tests");
|
||||
|
||||
// Enter comment
|
||||
cy.get(".modal .discussions-comment").type(
|
||||
"This is a discussion from the cypress ui tests."
|
||||
);
|
||||
|
||||
// Submit
|
||||
cy.get(".modal .submit-discussion").click();
|
||||
cy.wait(2000);
|
||||
|
||||
// Check if discussion is added to page and content is visible
|
||||
cy.get(".sidebar-parent:first .discussion-topic-title").should(
|
||||
"have.text",
|
||||
"Discussion from tests"
|
||||
);
|
||||
cy.get(".sidebar-parent:first .discussion-topic-title").click();
|
||||
cy.get(".discussion-on-page:visible").should("have.class", "show");
|
||||
cy.get(
|
||||
".discussion-on-page:visible .reply-card .reply-text .ql-editor p"
|
||||
).should(
|
||||
"have.text",
|
||||
"This is a discussion from the cypress ui tests."
|
||||
);
|
||||
|
||||
cy.get(".discussion-form:visible .discussions-comment").type(
|
||||
"This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page."
|
||||
);
|
||||
|
||||
cy.get(".discussion-form:visible .submit-discussion").click();
|
||||
cy.wait(3000);
|
||||
cy.get(".discussion-on-page:visible").should("have.class", "show");
|
||||
cy.get(".discussion-on-page:visible")
|
||||
.children(".reply-card")
|
||||
.eq(1)
|
||||
.find(".reply-text")
|
||||
.should(
|
||||
"have.text",
|
||||
"This is a discussion from the cypress ui tests. This comment was entered through the commentbox on the page.\n"
|
||||
cy.get("[id^=headlessui-dialog-panel-").within(() => {
|
||||
cy.get("label").contains("Title").type("Test Discussion");
|
||||
cy.get("div[contenteditable=true]").invoke(
|
||||
"text",
|
||||
"This is a test discussion. This will check if the UI is working properly."
|
||||
);
|
||||
cy.button("Post").click();
|
||||
});
|
||||
|
||||
// View Discussion
|
||||
cy.wait(500);
|
||||
cy.get("div").contains("Test Discussion").click();
|
||||
cy.get("div[contenteditable=true").invoke(
|
||||
"text",
|
||||
"This is a test comment. This will check if the UI is working properly."
|
||||
);
|
||||
|
||||
cy.get("div").contains(
|
||||
"This is a test comment. This will check if the UI is working properly."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
BIN
cypress/fixtures/Youtube.mov
Normal file
BIN
cypress/fixtures/Youtube.mov
Normal file
Binary file not shown.
BIN
cypress/fixtures/profile.png
Normal file
BIN
cypress/fixtures/profile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -24,6 +24,8 @@
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
|
||||
import "cypress-file-upload";
|
||||
|
||||
Cypress.Commands.add("login", (email, password) => {
|
||||
if (!email) {
|
||||
email = Cypress.config("testUser") || "Administrator";
|
||||
@@ -53,3 +55,13 @@ Cypress.Commands.add("iconButton", (text) => {
|
||||
Cypress.Commands.add("dialog", (selector) => {
|
||||
return cy.get(`[role=dialog] ${selector}`);
|
||||
});
|
||||
|
||||
Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
|
||||
cy.wrap(subject).then(($element) => {
|
||||
const element = $element[0];
|
||||
element.focus();
|
||||
element.textContent = text;
|
||||
const event = new Event("paste", { bubbles: true });
|
||||
element.dispatchEvent(event);
|
||||
});
|
||||
});
|
||||
|
||||
Submodule frappe-ui updated: c5faaae38e...38728b80aa
@@ -21,7 +21,7 @@
|
||||
"chart.js": "^4.4.1",
|
||||
"dayjs": "^1.11.6",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.50",
|
||||
"frappe-ui": "^0.1.56",
|
||||
"lucide-vue-next": "^0.309.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"pinia": "^2.0.33",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<SidebarLink
|
||||
v-for="link in links"
|
||||
v-for="link in sidebarLinks"
|
||||
:link="link"
|
||||
:isCollapsed="isSidebarCollapsed"
|
||||
class="mx-2 my-0.5"
|
||||
@@ -42,10 +42,53 @@ import UserDropdown from '@/components/UserDropdown.vue'
|
||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||
import SidebarLink from '@/components/SidebarLink.vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted, inject, computed } from 'vue'
|
||||
import { getSidebarLinks } from '../utils'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Bell } from 'lucide-vue-next'
|
||||
import { createResource } from 'frappe-ui'
|
||||
|
||||
const links = getSidebarLinks()
|
||||
const { user } = sessionStore()
|
||||
const socket = inject('$socket')
|
||||
const unreadCount = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
socket.on('publish_lms_notifications', (data) => {
|
||||
unreadNotifications.reload()
|
||||
})
|
||||
})
|
||||
|
||||
const unreadNotifications = createResource({
|
||||
cache: 'Unread Notifications Count',
|
||||
url: 'frappe.client.get_count',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Notification Log',
|
||||
filters: {
|
||||
for_user: user,
|
||||
read: 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
unreadCount.value = data
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const sidebarLinks = computed(() => {
|
||||
const links = getSidebarLinks()
|
||||
if (user) {
|
||||
links.push({
|
||||
label: 'Notifications',
|
||||
icon: Bell,
|
||||
to: 'Notifications',
|
||||
activeFor: ['Notifications'],
|
||||
count: unreadCount.value,
|
||||
})
|
||||
}
|
||||
return links
|
||||
})
|
||||
|
||||
const getSidebarFromStorage = () => {
|
||||
return useStorage('sidebar_is_collapsed', false)
|
||||
|
||||
@@ -19,8 +19,14 @@
|
||||
<ListView
|
||||
:columns="getCoursesColumns()"
|
||||
:rows="courses.data"
|
||||
row-key="name"
|
||||
:options="{ showTooltip: false }"
|
||||
row-key="batch_course"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
getRowRoute: (row) => ({
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: row.name },
|
||||
}),
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||
@@ -49,7 +55,10 @@
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button variant="ghost" @click="removeCourses(selections)">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="removeCourses(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -133,11 +142,13 @@ const removeCourse = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const removeCourses = (selections) => {
|
||||
const removeCourses = (selections, unselectAll) => {
|
||||
selections.forEach(async (course) => {
|
||||
removeCourse.submit({ course })
|
||||
await setTimeout(1000)
|
||||
})
|
||||
courses.reload()
|
||||
setTimeout(() => {
|
||||
courses.reload()
|
||||
unselectAll()
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="shadow rounded-md p-5" style="width: 300px">
|
||||
<div v-if="batch.data" class="shadow rounded-md p-5 lg:w-72">
|
||||
<Badge
|
||||
v-if="batch.data.seat_count && seats_left > 0"
|
||||
theme="green"
|
||||
|
||||
@@ -52,7 +52,10 @@
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button variant="ghost" @click="removeStudents(selections)">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="removeStudents(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -142,11 +145,13 @@ const removeStudent = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const removeStudents = (selections) => {
|
||||
const removeStudents = (selections, unselectAll) => {
|
||||
selections.forEach(async (student) => {
|
||||
removeStudent.submit({ student })
|
||||
await setTimeout(1000)
|
||||
})
|
||||
students.reload()
|
||||
setTimeout(() => {
|
||||
students.reload()
|
||||
unselectAll()
|
||||
}, 500)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div
|
||||
class="course-image"
|
||||
:class="{ 'default-image': !course.image }"
|
||||
:style="{ backgroundImage: 'url(' + encodeURI(course.image) + ')' }"
|
||||
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
|
||||
>
|
||||
<div class="flex relative top-4 left-4 w-fit flex-wrap">
|
||||
<Badge
|
||||
@@ -147,8 +147,8 @@ const props = defineProps({
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: theme('colors.orange.100');
|
||||
color: theme('colors.orange.600');
|
||||
background-color: theme('colors.green.100');
|
||||
color: theme('colors.green.600');
|
||||
}
|
||||
|
||||
.avatar-group {
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<div
|
||||
v-else-if="course.data.disable_self_learning"
|
||||
class="bg-blue-100 text-blue-900 text-sm rounded-md py-1 px-3"
|
||||
>
|
||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||
</div>
|
||||
<Button
|
||||
v-else
|
||||
@click="enrollStudent()"
|
||||
@@ -135,7 +141,6 @@ function enrollStudent() {
|
||||
const enrollStudentResource = createResource({
|
||||
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
||||
})
|
||||
console.log(props.course)
|
||||
enrollStudentResource
|
||||
.submit({
|
||||
course: props.course.data.name,
|
||||
@@ -160,5 +165,13 @@ function enrollStudent() {
|
||||
}
|
||||
}
|
||||
|
||||
const is_instructor = () => {}
|
||||
const is_instructor = () => {
|
||||
let user_is_instructor = false
|
||||
props.course.data.instructors.forEach((instructor) => {
|
||||
if (!user_is_instructor && instructor.name == user.data?.name) {
|
||||
user_is_instructor = true
|
||||
}
|
||||
})
|
||||
return user_is_instructor
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="text-base">
|
||||
<div
|
||||
v-if="title && (outline.data?.length || allowEdit)"
|
||||
class="flex items-center justify-between mb-4 pl-2"
|
||||
class="grid grid-cols-[70%,30%] mb-4"
|
||||
>
|
||||
<div class="font-semibold text-lg">
|
||||
{{ __(title) }}
|
||||
@@ -67,7 +67,7 @@
|
||||
{{ lesson.title }}
|
||||
<Check
|
||||
v-if="lesson.is_complete"
|
||||
class="h-4 w-4 text-green-500 stroke-1.5 ml-2"
|
||||
class="h-4 w-4 text-green-700 ml-2"
|
||||
/>
|
||||
</div>
|
||||
</router-link>
|
||||
@@ -105,7 +105,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, createResource } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||
import {
|
||||
ChevronRight,
|
||||
@@ -139,6 +139,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
getProgress: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const outline = createResource({
|
||||
@@ -146,6 +150,7 @@ const outline = createResource({
|
||||
cache: ['course_outline', props.courseName],
|
||||
params: {
|
||||
course: props.courseName,
|
||||
progress: props.getProgress,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<div
|
||||
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
<AppSidebar />
|
||||
</div>
|
||||
<div class="w-full overflow-auto" id="scrollContainer">
|
||||
|
||||
@@ -69,9 +69,11 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextEditor
|
||||
class="mt-5"
|
||||
:content="newReply"
|
||||
:mentions="mentionUsers"
|
||||
@change="(val) => (newReply = val)"
|
||||
placeholder="Type your reply here..."
|
||||
:fixedMenu="true"
|
||||
@@ -92,13 +94,14 @@ import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
|
||||
import { timeAgo } from '../utils'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||
import { ref, inject, onMounted } from 'vue'
|
||||
import { ref, inject, onMounted, computed } from 'vue'
|
||||
import { createToast } from '../utils'
|
||||
|
||||
const showTopics = defineModel('showTopics')
|
||||
const newReply = ref('')
|
||||
const socket = inject('$socket')
|
||||
const user = inject('$user')
|
||||
const allUsers = inject('$allUsers')
|
||||
|
||||
const props = defineProps({
|
||||
topic: {
|
||||
@@ -147,6 +150,16 @@ const newReplyResource = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const mentionUsers = computed(() => {
|
||||
let users = Object.values(allUsers.data).map((user) => {
|
||||
return {
|
||||
value: user.name,
|
||||
label: user.full_name,
|
||||
}
|
||||
})
|
||||
return users
|
||||
})
|
||||
|
||||
const postReply = () => {
|
||||
newReplyResource.submit(
|
||||
{},
|
||||
|
||||
@@ -42,14 +42,14 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center border mt-5 p-5 rounded-md"
|
||||
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
|
||||
>
|
||||
<MessageSquareIcon class="w-5 h-5 stroke-1.5 mr-2" />
|
||||
<div>
|
||||
<MessageSquareText class="w-7 h-7 text-gray-500 stroke-1.5 mr-2" />
|
||||
<div class="">
|
||||
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
||||
{{ __(emptyStateTitle) }}
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="text-gray-600">
|
||||
{{ __(emptyStateText) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,13 +63,14 @@
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Button, TextEditor } from 'frappe-ui'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { timeAgo } from '../utils'
|
||||
import { ref, onMounted, inject } from 'vue'
|
||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||
import { MessageSquareIcon } from 'lucide-vue-next'
|
||||
import { MessageSquareText } from 'lucide-vue-next'
|
||||
import { getScrollContainer } from '@/utils/scrollContainer'
|
||||
|
||||
const showTopics = ref(true)
|
||||
const currentTopic = ref(null)
|
||||
@@ -96,12 +97,16 @@ const props = defineProps({
|
||||
},
|
||||
emptyStateText: {
|
||||
type: String,
|
||||
default: 'Be the first to start a discussion',
|
||||
default: 'Start a discussion',
|
||||
},
|
||||
singleThread: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scrollToBottom: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -110,8 +115,19 @@ onMounted(() => {
|
||||
socket.on('new_discussion_topic', (data) => {
|
||||
topics.refresh()
|
||||
})
|
||||
|
||||
if (props.scrollToBottom) {
|
||||
setTimeout(() => {
|
||||
scrollToEnd()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
|
||||
const scrollToEnd = () => {
|
||||
let scrollContainer = getScrollContainer()
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
||||
}
|
||||
|
||||
const topics = createResource({
|
||||
url: 'lms.lms.utils.get_discussion_topics',
|
||||
cache: ['topics', props.doctype, props.docname],
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
{{
|
||||
uploading
|
||||
? __('Uploading {0}%').format(progress)
|
||||
: __('Upload an File')
|
||||
: __('Upload a File')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -29,15 +29,42 @@
|
||||
<script setup>
|
||||
import { getSidebarLinks } from '../utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, inject } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { LogOut, LogIn, UserRound } from 'lucide-vue-next'
|
||||
|
||||
const { logout, user } = sessionStore()
|
||||
let { isLoggedIn } = sessionStore()
|
||||
|
||||
const router = useRouter()
|
||||
let { userResource } = usersStore()
|
||||
|
||||
const tabs = computed(() => {
|
||||
return getSidebarLinks()
|
||||
let links = getSidebarLinks()
|
||||
|
||||
if (user) {
|
||||
links.push({
|
||||
label: 'Profile',
|
||||
icon: UserRound,
|
||||
activeFor: [
|
||||
'Profile',
|
||||
'ProfileAbout',
|
||||
'ProfileCertification',
|
||||
'ProfileEvaluator',
|
||||
'ProfileRoles',
|
||||
],
|
||||
})
|
||||
links.push({
|
||||
label: 'Log out',
|
||||
icon: LogOut,
|
||||
})
|
||||
} else {
|
||||
links.push({
|
||||
label: 'Log in',
|
||||
icon: LogIn,
|
||||
})
|
||||
}
|
||||
return links
|
||||
})
|
||||
|
||||
let isActive = (tab) => {
|
||||
@@ -50,6 +77,13 @@ const handleClick = (tab) => {
|
||||
logout.submit().then(() => {
|
||||
isLoggedIn = false
|
||||
})
|
||||
else if (tab.label == 'Profile')
|
||||
router.push({
|
||||
name: 'Profile',
|
||||
params: {
|
||||
username: userResource.data?.username,
|
||||
},
|
||||
})
|
||||
else router.push({ name: tab.to })
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
size: '2xl',
|
||||
actions: [
|
||||
{
|
||||
label: 'Submit',
|
||||
label: 'Post',
|
||||
variant: 'solid',
|
||||
onClick: (close) => submitTopic(close),
|
||||
},
|
||||
@@ -15,10 +15,7 @@
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Title') }}
|
||||
</div>
|
||||
<Input type="text" v-model="topic.title" />
|
||||
<FormControl v-model="topic.title" :label="__('Title')" type="text" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
@@ -37,8 +34,9 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
|
||||
import { reactive, defineModel } from 'vue'
|
||||
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
|
||||
import { reactive, defineModel, computed } from 'vue'
|
||||
import { showToast } from '@/utils'
|
||||
|
||||
const topics = defineModel('reloadTopics')
|
||||
|
||||
@@ -93,6 +91,14 @@ const submitTopic = (close) => {
|
||||
topicResource.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
if (!topic.title) {
|
||||
return 'Title cannot be empty.'
|
||||
}
|
||||
if (!topic.reply) {
|
||||
return 'Reply cannot be empty.'
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
replyResource.submit(
|
||||
{
|
||||
@@ -108,6 +114,9 @@ const submitTopic = (close) => {
|
||||
}
|
||||
)
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.message, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -253,8 +253,6 @@ const quiz = createResource({
|
||||
if (data.shuffle_questions) {
|
||||
data.questions = data.questions.sort(() => Math.random() - 0.5)
|
||||
}
|
||||
attempts.reload()
|
||||
resetQuiz()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -286,6 +284,16 @@ const attempts = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => quiz.data,
|
||||
() => {
|
||||
if (quiz.data) {
|
||||
attempts.reload()
|
||||
resetQuiz()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const quizSubmission = createResource({
|
||||
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
|
||||
makeParams(values) {
|
||||
@@ -315,7 +323,6 @@ watch(activeQuestion, (value) => {
|
||||
watch(
|
||||
() => props.quizName,
|
||||
(newName) => {
|
||||
console.log(newName)
|
||||
if (newName) {
|
||||
quiz.reload()
|
||||
}
|
||||
@@ -384,7 +391,7 @@ const addToLocalStorage = () => {
|
||||
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
||||
let questionData = {
|
||||
question_index: activeQuestion.value,
|
||||
answers: getAnswers().join(),
|
||||
answer: getAnswers().join(),
|
||||
is_correct: showAnswers.filter((answer) => {
|
||||
return answer != undefined
|
||||
}),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
@click="handleClick"
|
||||
>
|
||||
<div
|
||||
class="flex items-center duration-300 ease-in-out"
|
||||
class="flex items-center w-full duration-300 ease-in-out"
|
||||
:class="isCollapsed ? 'p-1' : 'px-2 py-1'"
|
||||
>
|
||||
<Tooltip :text="link.label" placement="right">
|
||||
@@ -29,6 +29,9 @@
|
||||
>
|
||||
{{ link.label }}
|
||||
</span>
|
||||
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
|
||||
{{ link.count }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -34,9 +34,7 @@ const props = defineProps({
|
||||
default: 'Tags',
|
||||
},
|
||||
})
|
||||
console.log(props.modelValue)
|
||||
let tags = ref(props.modelValue)
|
||||
console.log(tags.value)
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
let newTag = ref('')
|
||||
|
||||
|
||||
@@ -26,7 +26,12 @@
|
||||
"
|
||||
>
|
||||
<div class="text-base font-medium text-gray-900 leading-none">
|
||||
<span v-if="branding.data?.brand_name">
|
||||
<span
|
||||
v-if="
|
||||
branding.data?.brand_name &&
|
||||
branding.data?.brand_name != 'Frappe'
|
||||
"
|
||||
>
|
||||
{{ branding.data?.brand_name }}
|
||||
</span>
|
||||
<span v-else> Learning </span>
|
||||
@@ -57,13 +62,23 @@
|
||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Dropdown, createResource } from 'frappe-ui'
|
||||
import { ChevronDown, LogIn, LogOut, User } from 'lucide-vue-next'
|
||||
import {
|
||||
ChevronDown,
|
||||
LogIn,
|
||||
LogOut,
|
||||
User,
|
||||
ArrowRightLeft,
|
||||
} from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { convertToTitleCase } from '../utils'
|
||||
import { onMounted, inject } from 'vue'
|
||||
import { usersStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const { logout } = sessionStore()
|
||||
let { userResource } = usersStore()
|
||||
let { isLoggedIn } = sessionStore()
|
||||
|
||||
const props = defineProps({
|
||||
isCollapsed: {
|
||||
type: Boolean,
|
||||
@@ -80,10 +95,6 @@ const branding = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const { logout } = sessionStore()
|
||||
let { userResource } = usersStore()
|
||||
|
||||
let { isLoggedIn } = sessionStore()
|
||||
const userDropdownOptions = [
|
||||
{
|
||||
icon: User,
|
||||
@@ -95,6 +106,19 @@ const userDropdownOptions = [
|
||||
return isLoggedIn
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: ArrowRightLeft,
|
||||
label: 'Switch to Desk',
|
||||
onClick: () => {
|
||||
window.location.href = '/app'
|
||||
},
|
||||
condition: () => {
|
||||
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
||||
let system_user = cookies.get('system_user')
|
||||
if (system_user === 'yes') return true
|
||||
else return false
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: LogOut,
|
||||
label: 'Log out',
|
||||
|
||||
@@ -30,8 +30,9 @@ app.provide('$dayjs', dayjs)
|
||||
app.provide('$socket', initSocket())
|
||||
app.mount('#app')
|
||||
|
||||
const { userResource } = usersStore()
|
||||
const { userResource, allUsers } = usersStore()
|
||||
let { isLoggedIn } = sessionStore()
|
||||
|
||||
app.provide('$user', userResource)
|
||||
app.provide('$allUsers', allUsers)
|
||||
app.config.globalProperties.$user = userResource
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</template>
|
||||
</Button>
|
||||
</header>
|
||||
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-full">
|
||||
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-screen">
|
||||
<div class="border-r-2">
|
||||
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-x-visible">
|
||||
<template #tab="{ tab, selected }" class="overflow-x-hidden">
|
||||
@@ -66,9 +66,10 @@
|
||||
<Discussions
|
||||
doctype="LMS Batch"
|
||||
:docname="batch.data.name"
|
||||
title="Discussions"
|
||||
:title="__('Discussions')"
|
||||
:key="batch.data.name"
|
||||
:singleThread="true"
|
||||
:scrollToBottom="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -244,7 +244,7 @@ const newBatch = createResource({
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Batch',
|
||||
meta_image: batch.image.file_url,
|
||||
meta_image: batch.image?.file_url,
|
||||
...batch,
|
||||
},
|
||||
}
|
||||
@@ -279,7 +279,7 @@ const editBatch = createResource({
|
||||
doctype: 'LMS Batch',
|
||||
name: props.batchName,
|
||||
fieldname: {
|
||||
meta_image: batch.image.file_url,
|
||||
meta_image: batch.image?.file_url,
|
||||
...batch,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -11,17 +11,23 @@
|
||||
<div class="my-3">
|
||||
{{ batch.data.description }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-1/2">
|
||||
<div
|
||||
class="flex flex-col gap-2 lg:gap-0 lg:flex-row lg:items-center justify-between lg:w-1/2"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<BookOpen class="h-4 w-4 text-gray-700 mr-2" />
|
||||
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
|
||||
</div>
|
||||
<span v-if="batch.data.courses">·</span>
|
||||
<span class="hidden lg:block" v-if="batch.data.courses"
|
||||
>·</span
|
||||
>
|
||||
<DateRange
|
||||
:startDate="batch.data.start_date"
|
||||
:endDate="batch.data.end_date"
|
||||
/>
|
||||
<span v-if="batch.data.start_date">·</span>
|
||||
<span class="hidden lg:block" v-if="batch.data.start_date"
|
||||
>·</span
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Clock class="h-4 w-4 text-gray-700 mr-2" />
|
||||
<span>
|
||||
@@ -31,14 +37,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-[60%,20%] gap-20 mt-10">
|
||||
<div class="">
|
||||
<div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
|
||||
<div class="order-2 lg:order-none">
|
||||
<div
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-6"
|
||||
v-html="batch.data.batch_details"
|
||||
></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="order-1 lg:order-none">
|
||||
<BatchOverlay :batch="batch" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,7 +54,7 @@
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 mt-5">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-5">
|
||||
<div
|
||||
v-if="batch.data.courses"
|
||||
v-for="course in courses.data"
|
||||
@@ -79,7 +85,7 @@
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
|
||||
import { BookOpen, Clock } from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils'
|
||||
import { Breadcrumbs, createResource } from 'frappe-ui'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
/>
|
||||
<div class="flex">
|
||||
<router-link
|
||||
v-if="user.data"
|
||||
v-if="user.data?.is_moderator"
|
||||
:to="{
|
||||
name: 'BatchCreation',
|
||||
params: { batchName: 'new' },
|
||||
|
||||
@@ -3,9 +3,22 @@
|
||||
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" />
|
||||
<div>
|
||||
<FormControl
|
||||
type="text"
|
||||
placeholder="Search Participants"
|
||||
v-model="searchQuery"
|
||||
@input="participants.reload()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search class="w-4" name="search" />
|
||||
</template>
|
||||
</FormControl>
|
||||
</div>
|
||||
</header>
|
||||
<div class="grid grid-cols-3 gap-4 m-5">
|
||||
<div v-for="participant in participants.data">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 m-5">
|
||||
<div v-if="participants.data" v-for="participant in participants.data">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
@@ -38,14 +51,23 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, createResource } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import { Breadcrumbs, FormControl, createResource } from 'frappe-ui'
|
||||
import { ref, computed } from 'vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { Search } from 'lucide-vue-next'
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
const participants = createResource({
|
||||
url: 'lms.lms.api.get_certified_participants',
|
||||
method: 'GET',
|
||||
cache: ['certified_participants'],
|
||||
makeParams() {
|
||||
return {
|
||||
search_query: searchQuery.value,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
cache: ['certified-participants'],
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
|
||||
@@ -149,6 +149,12 @@ updateDocumentTitle(pageMeta)
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
.course-description ul {
|
||||
list-style: disc;
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -65,8 +65,8 @@
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: course.name,
|
||||
chapterNumber: course.current_lesson.split('.')[0],
|
||||
lessonNumber: course.current_lesson.split('.')[1],
|
||||
chapterNumber: course.current_lesson.split('-')[0],
|
||||
lessonNumber: course.current_lesson.split('-')[1],
|
||||
},
|
||||
}
|
||||
: course.membership
|
||||
|
||||
@@ -114,7 +114,11 @@
|
||||
@click="removeTag(tag)"
|
||||
/>
|
||||
</div>
|
||||
<FormControl v-model="newTag" @keyup.enter="updateTags()" />
|
||||
<FormControl
|
||||
v-model="newTag"
|
||||
@keyup.enter="updateTags()"
|
||||
id="tags"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,7 +126,10 @@
|
||||
<div class="text-lg font-semibold mt-5 mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div
|
||||
v-if="user.data?.is_moderator"
|
||||
class="flex items-center justify-between mb-4"
|
||||
>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.published"
|
||||
@@ -139,6 +146,12 @@
|
||||
:label="__('Disable Self Enrollment')"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="course.published_on"
|
||||
:label="__('Published On')"
|
||||
type="date"
|
||||
class="mb-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="container border-t">
|
||||
<div class="text-lg font-semibold mt-5 mb-4">
|
||||
@@ -197,6 +210,7 @@ import CourseOutline from '@/components/CourseOutline.vue'
|
||||
const user = inject('$user')
|
||||
const newTag = ref('')
|
||||
const router = useRouter()
|
||||
const instructors = ref([])
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
@@ -212,6 +226,7 @@ const course = reactive({
|
||||
course_image: null,
|
||||
tags: '',
|
||||
published: false,
|
||||
published_on: '',
|
||||
upcoming: false,
|
||||
disable_self_learning: false,
|
||||
paid_course: false,
|
||||
@@ -220,9 +235,14 @@ const course = reactive({
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
if (
|
||||
props.courseName == 'new' &&
|
||||
!user.data?.is_moderator &&
|
||||
!user.data?.is_instructor
|
||||
) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
|
||||
if (props.courseName !== 'new') {
|
||||
courseResource.reload()
|
||||
}
|
||||
@@ -234,7 +254,7 @@ const courseCreationResource = createResource({
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Course',
|
||||
image: course.course_image.file_url,
|
||||
image: course.course_image?.file_url || '',
|
||||
...values,
|
||||
},
|
||||
}
|
||||
@@ -249,7 +269,7 @@ const courseEditResource = createResource({
|
||||
doctype: 'LMS Course',
|
||||
name: values.course,
|
||||
fieldname: {
|
||||
image: course.course_image.file_url,
|
||||
image: course.course_image?.file_url || '',
|
||||
...course,
|
||||
},
|
||||
}
|
||||
@@ -281,6 +301,8 @@ const courseResource = createResource({
|
||||
}
|
||||
|
||||
if (data.image) imageResource.reload({ image: data.image })
|
||||
instructors.value = data.instructors
|
||||
check_permission()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -358,7 +380,7 @@ watch(
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
}
|
||||
}
|
||||
@@ -386,6 +408,21 @@ const removeImage = () => {
|
||||
course.course_image = null
|
||||
}
|
||||
|
||||
const check_permission = () => {
|
||||
let user_is_instructor = false
|
||||
if (user.data?.is_moderator) return
|
||||
|
||||
instructors.value.forEach((instructor) => {
|
||||
if (!user_is_instructor && instructor.instructor == user.data?.name) {
|
||||
user_is_instructor = true
|
||||
}
|
||||
})
|
||||
|
||||
if (!user_is_instructor) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<div
|
||||
v-show="openInstructorEditor"
|
||||
id="instructor-notes"
|
||||
class="py-3"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-6 py-3"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,7 +52,10 @@
|
||||
<label class="block font-medium text-gray-600 mb-1">
|
||||
{{ __('Content') }}
|
||||
</label>
|
||||
<div id="content" class="py-3"></div>
|
||||
<div
|
||||
id="content"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-6 py-3"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,12 +18,16 @@
|
||||
}}
|
||||
</p>
|
||||
<router-link
|
||||
v-if="user.data"
|
||||
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
|
||||
>
|
||||
<Button variant="solid">
|
||||
{{ __('Start Learning') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button v-else @click="redirectToLogin()">
|
||||
{{ __('Login') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="border-r container pt-5 pb-10 px-5">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between">
|
||||
@@ -151,7 +155,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="sticky top-10">
|
||||
<div class="bg-gray-50 py-5 pl-2 border-b">
|
||||
<div class="bg-gray-50 py-5 px-2 border-b">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ lesson.data.course_title }}
|
||||
</div>
|
||||
@@ -170,7 +174,11 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<CourseOutline :courseName="courseName" :key="chapterNumber" />
|
||||
<CourseOutline
|
||||
:courseName="courseName"
|
||||
:key="chapterNumber"
|
||||
:getProgress="lesson.data.membership ? true : false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,6 +336,10 @@ const allowInstructorContent = () => {
|
||||
if (lesson.data?.instructors.includes(user.data?.name)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.avatar-group {
|
||||
|
||||
157
frontend/src/pages/Notifications.vue
Normal file
157
frontend/src/pages/Notifications.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
@click="markAllAsRead.submit"
|
||||
:loading="markAllAsRead.loading"
|
||||
v-if="activeTab === 'Unread' && unReadNotifications.data?.length > 0"
|
||||
>
|
||||
{{ __('Mark all as read') }}
|
||||
</Button>
|
||||
<TabButtons
|
||||
class="inline-block"
|
||||
:buttons="[{ label: 'Unread', active: true }, { label: 'Read' }]"
|
||||
v-model="activeTab"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div class="w-3/4 mx-auto px-5 pt-6 divide-y">
|
||||
<div
|
||||
v-if="notifications?.length"
|
||||
v-for="log in notifications"
|
||||
class="flex items-center py-2 justify-between"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<UserAvatar :user="allUsers.data[log.from_user]" class="mr-2" />
|
||||
<div class="notification" v-html="log.subject"></div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Link
|
||||
v-if="log.link"
|
||||
:to="log.link"
|
||||
@click="markAsRead.submit({ name: log.name })"
|
||||
class="text-gray-600 font-medium text-sm hover:text-gray-700"
|
||||
>
|
||||
{{ __('View') }}
|
||||
</Link>
|
||||
<Tooltip :text="__('Mark as read')">
|
||||
<Button
|
||||
variant="ghost"
|
||||
v-if="!log.read"
|
||||
@click="markAsRead.submit({ name: log.name })"
|
||||
>
|
||||
<template #icon>
|
||||
<X class="h-4 w-4 text-gray-700 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-600">
|
||||
{{ __('Nothing to see here.') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
createListResource,
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Link,
|
||||
TabButtons,
|
||||
Button,
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, ref, onMounted } from 'vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
const user = inject('$user')
|
||||
const socket = inject('$socket')
|
||||
const allUsers = inject('$allUsers')
|
||||
const activeTab = ref('Unread')
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) router.push({ name: 'Courses' })
|
||||
|
||||
socket.on('publish_lms_notifications', (data) => {
|
||||
unReadNotifications.reload()
|
||||
})
|
||||
})
|
||||
|
||||
const notifications = computed(() => {
|
||||
return activeTab.value === 'Unread'
|
||||
? unReadNotifications.data
|
||||
: readNotifications.data
|
||||
})
|
||||
|
||||
const unReadNotifications = createListResource({
|
||||
doctype: 'Notification Log',
|
||||
fields: ['subject', 'from_user', 'link', 'read', 'name'],
|
||||
filters: {
|
||||
for_user: user.data?.name,
|
||||
read: 0,
|
||||
},
|
||||
orderBy: 'creation desc',
|
||||
auto: true,
|
||||
cache: 'Unread Notifications',
|
||||
})
|
||||
|
||||
const readNotifications = createListResource({
|
||||
doctype: 'Notification Log',
|
||||
fields: ['subject', 'from_user', 'link', 'read', 'name'],
|
||||
filters: {
|
||||
for_user: user.data?.name,
|
||||
read: 1,
|
||||
},
|
||||
orderBy: 'creation desc',
|
||||
auto: true,
|
||||
cache: 'Read Notifications',
|
||||
})
|
||||
|
||||
const markAsRead = createResource({
|
||||
url: 'lms.lms.api.mark_as_read',
|
||||
makeParams(values) {
|
||||
return {
|
||||
name: values.name,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
unReadNotifications.reload()
|
||||
readNotifications.reload()
|
||||
},
|
||||
})
|
||||
|
||||
const markAllAsRead = createResource({
|
||||
url: 'lms.lms.api.mark_all_as_read',
|
||||
onSuccess(data) {
|
||||
unReadNotifications.reload()
|
||||
readNotifications.reload()
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'Notifications',
|
||||
route: {
|
||||
name: 'Notifications',
|
||||
},
|
||||
},
|
||||
]
|
||||
return crumbs
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.notification strong {
|
||||
font-weight: 400;
|
||||
}
|
||||
.notification b {
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@@ -35,8 +35,8 @@
|
||||
</EditCoverImage>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-auto -mt-4 max-w-4xl translate-x-0 sm:px-5">
|
||||
<div class="flex items-center">
|
||||
<div class="mx-auto -mt-10 md:-mt-4 max-w-4xl translate-x-0 px-5">
|
||||
<div class="flex flex-col md:flex-row items-center">
|
||||
<div>
|
||||
<img
|
||||
v-if="profile.data.user_image"
|
||||
@@ -57,7 +57,11 @@
|
||||
{{ profile.data.headline }}
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="isSessionUser()" class="ml-auto" @click="editProfile()">
|
||||
<Button
|
||||
v-if="isSessionUser()"
|
||||
class="mt-3 sm:mt-0 md:ml-auto"
|
||||
@click="editProfile()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="mt-7">
|
||||
<div class="mt-7 mb-10">
|
||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||
{{ __('About') }}
|
||||
</h2>
|
||||
@@ -12,12 +12,92 @@
|
||||
{{ __('No introduction') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-7 mb-10">
|
||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||
{{ __('Achievements') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-5 gap-4">
|
||||
<div v-if="badges.data" v-for="badge in badges.data">
|
||||
<Popover trigger="hover">
|
||||
<template #target>
|
||||
<div class="relative">
|
||||
<img
|
||||
:src="badge.badge_image"
|
||||
:alt="badge.badge"
|
||||
class="h-[80px]"
|
||||
/>
|
||||
<div
|
||||
v-if="badge.count > 1"
|
||||
class="flex items-end bg-gray-100 p-2 text-xs font-semibold rounded-full absolute right-0 bottom-0"
|
||||
>
|
||||
<span>
|
||||
<X class="w-3 h-3" />
|
||||
</span>
|
||||
{{ badge.count }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div class="w-[250px] text-base">
|
||||
<img
|
||||
:src="badge.badge_image"
|
||||
:alt="badge.badge"
|
||||
class="bg-gray-100 rounded-t-md"
|
||||
/>
|
||||
<div class="p-5">
|
||||
<div class="text-2xl font-semibold mb-2">
|
||||
{{ badge.badge }}
|
||||
</div>
|
||||
<div class="leading-5 mb-4">
|
||||
{{ badge.badge_description }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs text-gray-700 font-medium mb-1">
|
||||
{{ __('Issued on') }}:
|
||||
</span>
|
||||
{{ dayjs(badge.issued_on).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import { createResource, Popover } from 'frappe-ui'
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const badges = createResource({
|
||||
url: 'frappe.client.get_list',
|
||||
params: {
|
||||
doctype: 'LMS Badge Assignment',
|
||||
fields: ['name', 'badge', 'badge_image', 'badge_description', 'issued_on'],
|
||||
filters: {
|
||||
member: props.profile.data.name,
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
transform(data) {
|
||||
let finalBadges = []
|
||||
let groupedBadges = Object.groupBy(data, ({ badge }) => badge)
|
||||
for (let badge in groupedBadges) {
|
||||
let badgeData = groupedBadges[badge][0]
|
||||
badgeData.count = groupedBadges[badge].length
|
||||
finalBadges.push(badgeData)
|
||||
}
|
||||
return finalBadges
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="mt-7">
|
||||
<div class="mt-7 mb-10">
|
||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||
{{ __('Certificates') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="grid grod-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="certificate in certificates.data"
|
||||
:key="certificate.name"
|
||||
@@ -34,13 +34,9 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const certificates = createResource({
|
||||
url: 'frappe.client.get_list',
|
||||
url: 'lms.lms.api.get_certificates',
|
||||
params: {
|
||||
doctype: 'LMS Certificate',
|
||||
fields: ['name', 'course', 'course_title', 'issue_date', 'template'],
|
||||
filters: {
|
||||
member: props.profile.data.name,
|
||||
},
|
||||
member: props.profile.data.name,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
</h2>
|
||||
|
||||
<div class="">
|
||||
<div class="grid grid-cols-4 gap-4 text-sm text-gray-700 mb-4">
|
||||
<div
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-4 text-sm text-gray-700 mb-4"
|
||||
>
|
||||
<div>
|
||||
{{ __('Day') }}
|
||||
</div>
|
||||
@@ -20,7 +22,7 @@
|
||||
<div
|
||||
v-if="evaluator.data"
|
||||
v-for="slot in evaluator.data.slots.schedule"
|
||||
class="grid grid-cols-4 gap-4 mb-4 group"
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4 group"
|
||||
>
|
||||
<FormControl
|
||||
type="select"
|
||||
@@ -44,7 +46,10 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 gap-4 mb-4" v-show="showSlotsTemplate">
|
||||
<div
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4"
|
||||
v-show="showSlotsTemplate"
|
||||
>
|
||||
<FormControl
|
||||
type="select"
|
||||
:options="days"
|
||||
@@ -74,7 +79,7 @@
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900">
|
||||
{{ __('I am unavailable') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<FormControl
|
||||
type="date"
|
||||
:label="__('From')"
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||
{{ __('Settings') }}
|
||||
</h2>
|
||||
<div class="flex justify-between w-3/4 mt-5">
|
||||
<div
|
||||
class="flex flex-col md:flex-row gap-4 md:gap-0 justify-between w-3/4 mt-5"
|
||||
>
|
||||
<FormControl
|
||||
:label="__('Moderator')"
|
||||
v-model="moderator"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div v-if="chartDetails.data" class="p-5">
|
||||
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div class="flex items-center shadow py-2 px-3 rounded-md">
|
||||
<div class="p-2 rounded-md bg-gray-100 mr-3">
|
||||
<BookOpen class="w-18 h-18 stroke-1.5 text-gray-700" />
|
||||
|
||||
@@ -130,6 +130,11 @@ const routes = [
|
||||
name: 'CertifiedParticipants',
|
||||
component: () => import('@/pages/CertifiedParticipants.vue'),
|
||||
},
|
||||
{
|
||||
path: '/notifications',
|
||||
name: 'Notifications',
|
||||
component: () => import('@/pages/Notifications.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
let router = createRouter({
|
||||
@@ -138,13 +143,21 @@ let router = createRouter({
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const { userResource } = usersStore()
|
||||
const { userResource, allUsers } = usersStore()
|
||||
let { isLoggedIn } = sessionStore()
|
||||
|
||||
try {
|
||||
if (isLoggedIn) {
|
||||
await userResource.reload()
|
||||
}
|
||||
if (
|
||||
isLoggedIn &&
|
||||
(to.name == 'Lesson' ||
|
||||
to.name == 'Batch' ||
|
||||
to.name == 'Notifications')
|
||||
) {
|
||||
await allUsers.reload()
|
||||
}
|
||||
} catch (error) {
|
||||
isLoggedIn = false
|
||||
}
|
||||
|
||||
@@ -11,7 +11,13 @@ export const usersStore = defineStore('lms-users', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const allUsers = createResource({
|
||||
url: 'lms.lms.api.get_all_users',
|
||||
cache: ['allUsers'],
|
||||
})
|
||||
|
||||
return {
|
||||
userResource,
|
||||
allUsers,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TrendingUp,
|
||||
Briefcase,
|
||||
GraduationCap,
|
||||
Bell,
|
||||
} from 'lucide-vue-next'
|
||||
import { Quiz } from '@/utils/quiz'
|
||||
import { Upload } from '@/utils/upload'
|
||||
@@ -350,6 +351,7 @@ export function getSidebarLinks() {
|
||||
label: 'Statistics',
|
||||
icon: TrendingUp,
|
||||
to: 'Statistics',
|
||||
activeFor: ['Statistics'],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ export class Quiz {
|
||||
}
|
||||
|
||||
save(blockContent) {
|
||||
console.log(blockContent)
|
||||
return {
|
||||
quiz: this.data.quiz,
|
||||
}
|
||||
|
||||
11
frontend/src/utils/scrollContainer.js
Normal file
11
frontend/src/utils/scrollContainer.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export function scrollTo(...options) {
|
||||
if (!options || options.length === 0) return
|
||||
const container = getScrollContainer()
|
||||
if (!container) return
|
||||
container.scrollTo(...options)
|
||||
}
|
||||
|
||||
export function getScrollContainer() {
|
||||
// window.scrollContainer is reference to the scroll container in DesktopLayout.vue and MobileLayout.vue
|
||||
return window.scrollContainer
|
||||
}
|
||||
2081
frontend/yarn.lock
2081
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
50
lms/fixtures/lms_badge.json
Normal file
50
lms/fixtures/lms_badge.json
Normal file
@@ -0,0 +1,50 @@
|
||||
[
|
||||
{
|
||||
"condition": "{\n \"parent\": \"CLS-03050\"\n}",
|
||||
"description": "You have successfully completed the VueJs + Frappe UI training.",
|
||||
"docstatus": 0,
|
||||
"doctype": "LMS Badge",
|
||||
"enabled": 0,
|
||||
"event": "Auto Assign",
|
||||
"field_to_check": null,
|
||||
"grant_only_once": 1,
|
||||
"image": "/files/images.jpeg",
|
||||
"modified": "2024-05-14 12:56:05.031313",
|
||||
"name": "Batch Completion",
|
||||
"reference_doctype": "Batch Student",
|
||||
"title": "Batch Completion",
|
||||
"user_field": "student"
|
||||
},
|
||||
{
|
||||
"condition": "doc.progress == float(\"100.0\")",
|
||||
"description": "You have completed your first course 👏",
|
||||
"docstatus": 0,
|
||||
"doctype": "LMS Badge",
|
||||
"enabled": 0,
|
||||
"event": "Value Change",
|
||||
"field_to_check": "progress",
|
||||
"grant_only_once": 1,
|
||||
"image": "/files/icon_badge-04.png",
|
||||
"modified": "2024-05-14 12:56:15.469656",
|
||||
"name": "Course Completion",
|
||||
"reference_doctype": "LMS Enrollment",
|
||||
"title": "Course Completion",
|
||||
"user_field": "member"
|
||||
},
|
||||
{
|
||||
"condition": "doc.percentage == 100",
|
||||
"description": "Congratulations on getting a 100% score on a quiz.",
|
||||
"docstatus": 0,
|
||||
"doctype": "LMS Badge",
|
||||
"enabled": 0,
|
||||
"event": "New",
|
||||
"field_to_check": null,
|
||||
"grant_only_once": 1,
|
||||
"image": "/files/curiosity-badge-removebg-preview.png",
|
||||
"modified": "2024-05-14 12:56:22.907584",
|
||||
"name": "Quiz Completion",
|
||||
"reference_doctype": "LMS Quiz Submission",
|
||||
"title": "Quiz Completion",
|
||||
"user_field": "member"
|
||||
}
|
||||
]
|
||||
15
lms/hooks.py
15
lms/hooks.py
@@ -97,7 +97,13 @@ override_doctype_class = {
|
||||
# Hook on document methods and events
|
||||
|
||||
doc_events = {
|
||||
"*": {
|
||||
"on_change": [
|
||||
"lms.lms.doctype.lms_badge.lms_badge.process_badges",
|
||||
]
|
||||
},
|
||||
"Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"},
|
||||
"Notification Log": {"on_change": "lms.lms.utils.publish_notifications"},
|
||||
}
|
||||
|
||||
# Scheduled Tasks
|
||||
@@ -108,7 +114,7 @@ scheduler_events = {
|
||||
]
|
||||
}
|
||||
|
||||
fixtures = ["Custom Field", "Function", "Industry"]
|
||||
fixtures = ["Custom Field", "Function", "Industry", "LMS Badge"]
|
||||
|
||||
# Testing
|
||||
# -------
|
||||
@@ -146,9 +152,8 @@ website_redirects = [
|
||||
{"source": "/update-profile", "target": "/edit-profile"},
|
||||
{"source": "/courses", "target": "/lms/courses"},
|
||||
{
|
||||
"source": r"/courses/([^/]*)",
|
||||
"source": r"^/courses/.*$",
|
||||
"target": "/lms/courses",
|
||||
"match_with_query_string": True,
|
||||
},
|
||||
{"source": "/batches", "target": "/lms/batches"},
|
||||
{
|
||||
@@ -232,7 +237,8 @@ jinja = {
|
||||
# ]
|
||||
|
||||
has_website_permission = {
|
||||
"LMS Certificate Evaluation": "lms.lms.doctype.lms_certificate_evaluation.lms_certificate_evaluation.has_website_permission"
|
||||
"LMS Certificate Evaluation": "lms.lms.doctype.lms_certificate_evaluation.lms_certificate_evaluation.has_website_permission",
|
||||
"LMS Certificate": "lms.lms.doctype.lms_certificate.lms_certificate.has_website_permission",
|
||||
}
|
||||
|
||||
profile_mandatory_fields = [
|
||||
@@ -270,6 +276,7 @@ lms_markdown_macro_renderers = {
|
||||
page_renderer = [
|
||||
"lms.page_renderers.ProfileRedirectPage",
|
||||
"lms.page_renderers.ProfilePage",
|
||||
"lms.page_renderers.CoursePage",
|
||||
]
|
||||
|
||||
# set this to "/" to have profiles on the top-level
|
||||
|
||||
@@ -330,12 +330,13 @@ def get_evaluator_details(evaluator):
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_certified_participants():
|
||||
def get_certified_participants(search_query=""):
|
||||
LMSCertificate = DocType("LMS Certificate")
|
||||
participants = (
|
||||
frappe.qb.from_(LMSCertificate)
|
||||
.select(LMSCertificate.member)
|
||||
.distinct()
|
||||
.where(LMSCertificate.member_name.like(f"%{search_query}%"))
|
||||
.where(LMSCertificate.published == 1)
|
||||
.orderby(LMSCertificate.creation, order=frappe.qb.desc)
|
||||
.run(as_dict=1)
|
||||
@@ -355,7 +356,62 @@ def get_certified_participants():
|
||||
courses = []
|
||||
for course in course_names:
|
||||
courses.append(frappe.db.get_value("LMS Course", course, "title"))
|
||||
details.courses = courses
|
||||
details["courses"] = courses
|
||||
participant_details.append(details)
|
||||
|
||||
return participant_details
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_assigned_badges(member):
|
||||
assigned_badges = frappe.get_all(
|
||||
"LMS Badge Assignment",
|
||||
{"member": member},
|
||||
["badge"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
for badge in assigned_badges:
|
||||
badge.update(
|
||||
frappe.db.get_value("LMS Badge", badge.badge, ["name", "title", "image"])
|
||||
)
|
||||
return assigned_badges
|
||||
|
||||
|
||||
def get_certificates(member):
|
||||
"""Get certificates for a member."""
|
||||
return frappe.get_all(
|
||||
"LMS Certificate",
|
||||
filters={"member": member},
|
||||
fields=["name", "course", "course_title", "issue_date", "template"],
|
||||
order_by="creation desc",
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_all_users():
|
||||
users = frappe.get_all(
|
||||
"User",
|
||||
{
|
||||
"enabled": 1,
|
||||
},
|
||||
["name", "full_name", "user_image"],
|
||||
)
|
||||
|
||||
return {user.name: user for user in users}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def mark_as_read(name):
|
||||
doc = frappe.get_doc("Notification Log", name)
|
||||
doc.read = 1
|
||||
doc.save(ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def mark_all_as_read():
|
||||
notifications = frappe.get_all(
|
||||
"Notification Log", {"for_user": frappe.session.user, "read": 0}, pluck="name"
|
||||
)
|
||||
|
||||
for notification in notifications:
|
||||
mark_as_read(notification)
|
||||
|
||||
@@ -15,12 +15,6 @@ class CourseEvaluator(Document):
|
||||
self.validate_unavailability()
|
||||
|
||||
def validate_unavailability(self):
|
||||
if self.unavailable_from and not self.unavailable_to:
|
||||
frappe.throw(_("Unavailable To Date is mandatory if Unavailable From Date is set"))
|
||||
|
||||
if self.unavailable_to and not self.unavailable_from:
|
||||
frappe.throw(_("Unavailable From Date is mandatory if Unavailable To Date is set"))
|
||||
|
||||
if (
|
||||
self.unavailable_from
|
||||
and self.unavailable_to
|
||||
|
||||
@@ -7,6 +7,7 @@ from frappe.model.document import Document
|
||||
from frappe.utils.telemetry import capture
|
||||
from lms.lms.utils import get_course_progress
|
||||
from ...md import find_macros
|
||||
import json
|
||||
|
||||
|
||||
class CourseLesson(Document):
|
||||
@@ -89,7 +90,7 @@ class CourseLesson(Document):
|
||||
@frappe.whitelist()
|
||||
def save_progress(lesson, course):
|
||||
membership = frappe.db.exists(
|
||||
"LMS Enrollment", {"member": frappe.session.user, "course": course}
|
||||
"LMS Enrollment", {"course": course, "member": frappe.session.user}
|
||||
)
|
||||
if not membership:
|
||||
return 0
|
||||
@@ -114,23 +115,52 @@ def save_progress(lesson, course):
|
||||
|
||||
progress = get_course_progress(course)
|
||||
frappe.db.set_value("LMS Enrollment", membership, "progress", progress)
|
||||
enrollment = frappe.get_doc("LMS Enrollment", membership)
|
||||
enrollment.run_method("on_change")
|
||||
return progress
|
||||
|
||||
|
||||
def get_quiz_progress(lesson):
|
||||
body = frappe.db.get_value("Course Lesson", lesson, "body")
|
||||
macros = find_macros(body)
|
||||
quizzes = [value for name, value in macros if name == "Quiz"]
|
||||
lesson_details = frappe.db.get_value(
|
||||
"Course Lesson", lesson, ["body", "content"], as_dict=1
|
||||
)
|
||||
quizzes = []
|
||||
|
||||
if lesson_details.content:
|
||||
content = json.loads(lesson_details.content)
|
||||
|
||||
for block in content.get("blocks"):
|
||||
if block.get("type") == "quiz":
|
||||
quizzes.append(block.get("data").get("quiz"))
|
||||
|
||||
elif lesson_details.body:
|
||||
macros = find_macros(lesson_details.body)
|
||||
quizzes = [value for name, value in macros if name == "Quiz"]
|
||||
|
||||
for quiz in quizzes:
|
||||
print(quiz)
|
||||
passing_percentage = frappe.db.get_value("LMS Quiz", quiz, "passing_percentage")
|
||||
print(frappe.session.user)
|
||||
print(passing_percentage)
|
||||
print(
|
||||
frappe.db.exists(
|
||||
"LMS Quiz Submission",
|
||||
{
|
||||
"quiz": quiz,
|
||||
"member": frappe.session.user,
|
||||
"percentage": [">=", passing_percentage],
|
||||
},
|
||||
)
|
||||
)
|
||||
if not frappe.db.exists(
|
||||
"LMS Quiz Submission",
|
||||
{
|
||||
"quiz": quiz,
|
||||
"owner": frappe.session.user,
|
||||
"member": frappe.session.user,
|
||||
"percentage": [">=", passing_percentage],
|
||||
},
|
||||
):
|
||||
print("no submission")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
0
lms/lms/doctype/lms_badge/__init__.py
Normal file
0
lms/lms/doctype/lms_badge/__init__.py
Normal file
63
lms/lms/doctype/lms_badge/lms_badge.js
Normal file
63
lms/lms/doctype/lms_badge/lms_badge.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2024, Frappe and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("LMS Badge", {
|
||||
refresh: (frm) => {
|
||||
frm.events.set_field_options(frm);
|
||||
|
||||
if (frm.doc.event == "Auto Assign") {
|
||||
add_assign_button(frm);
|
||||
}
|
||||
},
|
||||
reference_doctype: (frm) => {
|
||||
frm.events.set_field_options(frm);
|
||||
},
|
||||
|
||||
set_field_options: (frm) => {
|
||||
const reference_doctype = frm.doc.reference_doctype;
|
||||
if (!reference_doctype) return;
|
||||
|
||||
frappe.model.with_doctype(reference_doctype, () => {
|
||||
const map_for_options = (df) => ({
|
||||
label: df.label,
|
||||
value: df.fieldname,
|
||||
});
|
||||
const fields = frappe.meta
|
||||
.get_docfields(frm.doc.reference_doctype)
|
||||
.filter(frappe.model.is_value_type);
|
||||
|
||||
const fields_to_check = fields.map(map_for_options);
|
||||
|
||||
const user_fields = fields
|
||||
.filter(
|
||||
(df) =>
|
||||
(df.fieldtype === "Link" && df.options === "User") ||
|
||||
df.fieldtype === "Data"
|
||||
)
|
||||
.map(map_for_options)
|
||||
.concat([
|
||||
{ label: __("Owner"), value: "owner" },
|
||||
{ label: __("Modified By"), value: "modified_by" },
|
||||
]);
|
||||
|
||||
frm.set_df_property("field_to_check", "options", fields_to_check);
|
||||
frm.set_df_property("user_field", "options", user_fields);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const add_assign_button = (frm) => {
|
||||
frm.add_custom_button(__("Assign"), function () {
|
||||
frappe.call({
|
||||
method: "lms.lms.doctype.lms_badge.lms_badge.assign_badge",
|
||||
args: {
|
||||
badge: frm.doc,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frappe.msgprint(r.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
126
lms/lms/doctype/lms_badge/lms_badge.json
Normal file
126
lms/lms/doctype/lms_badge/lms_badge.json
Normal file
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:title",
|
||||
"creation": "2024-04-30 11:29:53.548647",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"enabled",
|
||||
"title",
|
||||
"description",
|
||||
"image",
|
||||
"column_break_wgum",
|
||||
"grant_only_once",
|
||||
"event",
|
||||
"reference_doctype",
|
||||
"user_field",
|
||||
"field_to_check",
|
||||
"condition"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Title",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "image",
|
||||
"fieldtype": "Attach Image",
|
||||
"label": "Image",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_wgum",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Reference Document Type",
|
||||
"options": "DocType",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "event",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Event",
|
||||
"options": "New\nValue Change\nAuto Assign",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "condition",
|
||||
"fieldtype": "Code",
|
||||
"label": "Condition",
|
||||
"mandatory_depends_on": "eval:doc.event == \"Auto Assign\""
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.event == 'Value Change'",
|
||||
"fieldname": "field_to_check",
|
||||
"fieldtype": "Select",
|
||||
"label": "Field To Check"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "grant_only_once",
|
||||
"fieldtype": "Check",
|
||||
"label": "Grant only once"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "enabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enabled"
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Description",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "user_field",
|
||||
"fieldtype": "Select",
|
||||
"label": "User Field",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [
|
||||
{
|
||||
"link_doctype": "LMS Badge Assignment",
|
||||
"link_fieldname": "badge"
|
||||
}
|
||||
],
|
||||
"modified": "2024-05-14 14:46:13.644382",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Badge",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "title",
|
||||
"track_changes": 1
|
||||
}
|
||||
97
lms/lms/doctype/lms_badge/lms_badge.py
Normal file
97
lms/lms/doctype/lms_badge/lms_badge.py
Normal file
@@ -0,0 +1,97 @@
|
||||
# Copyright (c) 2024, Frappe and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
import json
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class LMSBadge(Document):
|
||||
def on_update(self):
|
||||
if self.event == "Auto Assign" and self.condition:
|
||||
try:
|
||||
json.loads(self.condition)
|
||||
except ValueError:
|
||||
frappe.throw("Condition must be in valid JSON format.")
|
||||
elif self.condition:
|
||||
try:
|
||||
compile(self.condition, "<string>", "eval")
|
||||
except Exception:
|
||||
frappe.throw("Condition must be valid python code.")
|
||||
|
||||
def apply(self, doc):
|
||||
if self.rule_condition_satisfied(doc):
|
||||
award(self, doc.get(self.user_field))
|
||||
|
||||
def rule_condition_satisfied(self, doc):
|
||||
doc_before_save = doc.get_doc_before_save()
|
||||
|
||||
if self.event == "Manual Assignment":
|
||||
return False
|
||||
|
||||
if self.event == "New" and doc_before_save != None:
|
||||
return False
|
||||
|
||||
if self.event == "Value Change":
|
||||
field_to_check = self.field_to_check
|
||||
if not field_to_check:
|
||||
return False
|
||||
|
||||
if self.condition:
|
||||
return eval_condition(doc, self.condition)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def award(doc, member):
|
||||
if doc.grant_only_once:
|
||||
if frappe.db.exists(
|
||||
"LMS Badge Assignment",
|
||||
{"badge": doc.name, "member": member},
|
||||
):
|
||||
return
|
||||
|
||||
assignment = frappe.new_doc("LMS Badge Assignment")
|
||||
assignment.update(
|
||||
{
|
||||
"badge": doc.name,
|
||||
"member": member,
|
||||
"issued_on": frappe.utils.now(),
|
||||
}
|
||||
)
|
||||
assignment.save()
|
||||
|
||||
|
||||
def eval_condition(doc, condition):
|
||||
return condition and frappe.safe_eval(condition, None, {"doc": doc.as_dict()})
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def assign_badge(badge):
|
||||
badge = frappe._dict(json.loads(badge))
|
||||
if not badge.event == "Auto Assign":
|
||||
return
|
||||
|
||||
fields = ["name"]
|
||||
print(badge.user_field)
|
||||
fields.append(badge.user_field)
|
||||
list = frappe.get_all(badge.reference_doctype, filters=badge.condition, fields=fields)
|
||||
print(list)
|
||||
for doc in list:
|
||||
award(badge, doc.get(badge.user_field))
|
||||
|
||||
|
||||
def process_badges(doc, state):
|
||||
if (
|
||||
frappe.flags.in_patch
|
||||
or frappe.flags.in_install
|
||||
or frappe.flags.in_migrate
|
||||
or frappe.flags.in_import
|
||||
or frappe.flags.in_setup_wizard
|
||||
):
|
||||
return
|
||||
|
||||
for d in frappe.cache_manager.get_doctype_map(
|
||||
"LMS Badge", doc.doctype, dict(reference_doctype=doc.doctype, enabled=1)
|
||||
):
|
||||
frappe.get_doc("LMS Badge", d.get("name")).apply(doc)
|
||||
9
lms/lms/doctype/lms_badge/test_lms_badge.py
Normal file
9
lms/lms/doctype/lms_badge/test_lms_badge.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2024, Frappe and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestLMSBadge(FrappeTestCase):
|
||||
pass
|
||||
0
lms/lms/doctype/lms_badge_assignment/__init__.py
Normal file
0
lms/lms/doctype/lms_badge_assignment/__init__.py
Normal file
14
lms/lms/doctype/lms_badge_assignment/lms_badge_assignment.js
Normal file
14
lms/lms/doctype/lms_badge_assignment/lms_badge_assignment.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2024, Frappe and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("LMS Badge Assignment", {
|
||||
refresh(frm) {
|
||||
frm.set_query("member", function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
ignore_user_type: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
122
lms/lms/doctype/lms_badge_assignment/lms_badge_assignment.json
Normal file
122
lms/lms/doctype/lms_badge_assignment/lms_badge_assignment.json
Normal file
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2024-04-30 11:58:44.096879",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"member",
|
||||
"issued_on",
|
||||
"column_break_ugix",
|
||||
"badge",
|
||||
"badge_image",
|
||||
"badge_description"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "member",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Member",
|
||||
"options": "User",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "badge",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Badge",
|
||||
"options": "LMS Badge",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "issued_on",
|
||||
"fieldtype": "Date",
|
||||
"in_list_view": 1,
|
||||
"label": "Issued On",
|
||||
"options": "Today",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "badge.image",
|
||||
"fieldname": "badge_image",
|
||||
"fieldtype": "Attach",
|
||||
"label": "Badge Image",
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ugix",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fetch_from": "badge.description",
|
||||
"fieldname": "badge_description",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Badge Description",
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-05-13 20:16:00.191517",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Badge Assignment",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Moderator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"if_owner": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "LMS Student",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "LMS Student",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "member"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2024, Frappe and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class LMSBadgeAssignment(Document):
|
||||
pass
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2024, Frappe and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestLMSBadgeAssignment(FrappeTestCase):
|
||||
pass
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "format: CLS-{#####}",
|
||||
"creation": "2022-11-09 16:14:05.876933",
|
||||
@@ -304,7 +305,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-04-17 10:35:21.957961",
|
||||
"modified": "2024-05-14 14:47:48.839162",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Batch",
|
||||
@@ -352,5 +353,6 @@
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "title"
|
||||
"title_field": "title",
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"creation": "2021-08-16 15:47:19.494055",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -87,7 +88,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-04-09 13:42:18.350028",
|
||||
"modified": "2024-05-14 14:48:31.650107",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Certificate",
|
||||
@@ -116,6 +117,15 @@
|
||||
"role": "Moderator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "LMS Student",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "modified",
|
||||
|
||||
@@ -70,6 +70,12 @@ class LMSCertificate(Document):
|
||||
)
|
||||
|
||||
|
||||
def has_website_permission(doc, ptype, user, verbose=False):
|
||||
if ptype in ["read", "print"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_certificate(course):
|
||||
certificate = is_certified(course)
|
||||
@@ -91,7 +97,13 @@ def create_certificate(course):
|
||||
},
|
||||
"value",
|
||||
)
|
||||
|
||||
if not default_certificate_template:
|
||||
default_certificate_template = frappe.db.get_value(
|
||||
"Print Format",
|
||||
{
|
||||
"doc_type": "LMS Certificate",
|
||||
},
|
||||
)
|
||||
certificate = frappe.get_doc(
|
||||
{
|
||||
"doctype": "LMS Certificate",
|
||||
|
||||
@@ -76,8 +76,7 @@
|
||||
{
|
||||
"fieldname": "video_link",
|
||||
"fieldtype": "Data",
|
||||
"label": "Video Embed Link",
|
||||
"reqd": 1
|
||||
"label": "Video Embed Link"
|
||||
},
|
||||
{
|
||||
"fieldname": "short_introduction",
|
||||
@@ -274,7 +273,7 @@
|
||||
}
|
||||
],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2024-04-16 17:40:50.899368",
|
||||
"modified": "2024-05-08 15:11:07.833094",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Course",
|
||||
@@ -305,7 +304,6 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"search_fields": "title",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
|
||||
@@ -5,7 +5,7 @@ import json
|
||||
import random
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint
|
||||
from frappe.utils import cint, today
|
||||
from frappe.utils.telemetry import capture
|
||||
from lms.lms.utils import get_chapters, can_create_courses
|
||||
from ...utils import generate_slug, validate_image
|
||||
@@ -14,11 +14,16 @@ from frappe import _
|
||||
|
||||
class LMSCourse(Document):
|
||||
def validate(self):
|
||||
self.validate_published()
|
||||
self.validate_instructors()
|
||||
self.validate_video_link()
|
||||
self.validate_status()
|
||||
self.image = validate_image(self.image)
|
||||
|
||||
def validate_published(self):
|
||||
if self.published and not self.published_on:
|
||||
self.published_on = today()
|
||||
|
||||
def validate_instructors(self):
|
||||
if self.is_new() and not self.instructors:
|
||||
frappe.get_doc(
|
||||
|
||||
@@ -72,6 +72,8 @@ def new_course(title, additional_filters=None):
|
||||
"title": title,
|
||||
"short_introduction": title,
|
||||
"description": title,
|
||||
"video_link": "https://youtu.be/pEbIhUySqbk",
|
||||
"image": "/assets/lms/images/course-home.png",
|
||||
}
|
||||
|
||||
if additional_filters:
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
# Copyright (c) 2021, FOSS United and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from lms.lms.utils import get_course_progress
|
||||
|
||||
|
||||
class LMSCourseProgress(Document):
|
||||
pass
|
||||
def after_delete(self):
|
||||
progress = get_course_progress(self.course, self.member)
|
||||
membership = frappe.db.get_value(
|
||||
"LMS Enrollment",
|
||||
{
|
||||
"member": self.member,
|
||||
"course": self.course,
|
||||
},
|
||||
"name",
|
||||
)
|
||||
frappe.db.set_value("LMS Enrollment", membership, "progress", progress)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"creation": "2022-02-07 12:01:40.929633",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"course",
|
||||
"member_type",
|
||||
"progress",
|
||||
"payment",
|
||||
"current_lesson",
|
||||
"column_break_3",
|
||||
"member",
|
||||
"member_name",
|
||||
@@ -17,8 +19,7 @@
|
||||
"subgroup",
|
||||
"batch_old",
|
||||
"column_break_12",
|
||||
"current_lesson",
|
||||
"progress",
|
||||
"member_type",
|
||||
"role"
|
||||
],
|
||||
"fields": [
|
||||
@@ -113,7 +114,8 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_8",
|
||||
"fieldtype": "Section Break"
|
||||
"fieldtype": "Section Break",
|
||||
"hidden": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "payment",
|
||||
@@ -124,7 +126,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-18 17:32:30.182301",
|
||||
"modified": "2024-05-14 14:50:08.405033",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Enrollment",
|
||||
@@ -173,5 +175,6 @@
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "member_name"
|
||||
"title_field": "member_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -103,6 +103,7 @@ def quiz_summary(quiz, results):
|
||||
"passing_percentage": quiz_details.passing_percentage,
|
||||
}
|
||||
)
|
||||
submission.save(ignore_permissions=True)
|
||||
|
||||
if (
|
||||
percentage >= quiz_details.passing_percentage
|
||||
@@ -110,8 +111,8 @@ def quiz_summary(quiz, results):
|
||||
and quiz_details.course
|
||||
):
|
||||
save_progress(quiz_details.lesson, quiz_details.course)
|
||||
|
||||
submission.save(ignore_permissions=True)
|
||||
elif not quiz_details.passing_percentage:
|
||||
save_progress(quiz_details.lesson, quiz_details.course)
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "answer",
|
||||
"fieldtype": "Data",
|
||||
"fieldtype": "Small Text",
|
||||
"in_list_view": 1,
|
||||
"label": "Users Response",
|
||||
"read_only": 1
|
||||
@@ -61,7 +61,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-10-17 11:55:25.641214",
|
||||
"modified": "2024-05-17 17:38:51.760653",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Quiz Result",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% set title = frappe.db.get_value("LMS Course", doc.course, "title") %}
|
||||
|
||||
<p> {{ _('Your evaluation for the course ${0} has been scheduled on ${1} at ${2}.').format(title, frappe.utils.format_date(doc.date, "medium"), frappe.utils.format_time(doc.start_time, "short")) }}</p>
|
||||
<p> {{ _('Your evaluation for the course {0} has been scheduled on {1} at {2}.').format(title, frappe.utils.format_date(doc.date, "medium"), frappe.utils.format_time(doc.start_time, "short")) }}</p>
|
||||
|
||||
<p> {{ _("Please prepare well and be on time for the evaluations.") }} </p>
|
||||
|
||||
122
lms/lms/utils.py
122
lms/lms/utils.py
@@ -115,27 +115,27 @@ def get_chapters(course):
|
||||
return chapters
|
||||
|
||||
|
||||
def get_lessons(course, chapter=None, get_details=True):
|
||||
def get_lessons(course, chapter=None, get_details=True, progress=False):
|
||||
"""If chapter is passed, returns lessons of only that chapter.
|
||||
Else returns lessons of all chapters of the course"""
|
||||
lessons = []
|
||||
lesson_count = 0
|
||||
if chapter:
|
||||
if get_details:
|
||||
return get_lesson_details(chapter)
|
||||
return get_lesson_details(chapter, progress=progress)
|
||||
else:
|
||||
return frappe.db.count("Lesson Reference", {"parent": chapter.name})
|
||||
|
||||
for chapter in get_chapters(course):
|
||||
if get_details:
|
||||
lessons += get_lesson_details(chapter)
|
||||
lessons += get_lesson_details(chapter, progress=progress)
|
||||
else:
|
||||
lesson_count += frappe.db.count("Lesson Reference", {"parent": chapter.name})
|
||||
|
||||
return lessons if get_details else lesson_count
|
||||
|
||||
|
||||
def get_lesson_details(chapter):
|
||||
def get_lesson_details(chapter, progress=False):
|
||||
lessons = []
|
||||
lesson_list = frappe.get_all(
|
||||
"Lesson Reference", {"parent": chapter.name}, ["lesson", "idx"], order_by="idx"
|
||||
@@ -161,6 +161,10 @@ def get_lesson_details(chapter):
|
||||
)
|
||||
lesson_details.number = f"{chapter.idx}.{row.idx}"
|
||||
lesson_details.icon = get_lesson_icon(lesson_details.body)
|
||||
|
||||
if progress:
|
||||
lesson_details.is_complete = get_progress(lesson_details.course, lesson_details.name)
|
||||
|
||||
lessons.append(lesson_details)
|
||||
return lessons
|
||||
|
||||
@@ -277,21 +281,21 @@ def get_lesson_index(lesson_name):
|
||||
"Lesson Reference", {"lesson": lesson_name}, ["idx", "parent"], as_dict=True
|
||||
)
|
||||
if not lesson:
|
||||
return "1.1"
|
||||
return "1-1"
|
||||
|
||||
chapter = frappe.db.get_value(
|
||||
"Chapter Reference", {"chapter": lesson.parent}, ["idx"], as_dict=True
|
||||
)
|
||||
if not chapter:
|
||||
return "1.1"
|
||||
return "1-1"
|
||||
|
||||
return f"{chapter.idx}.{lesson.idx}"
|
||||
return f"{chapter.idx}-{lesson.idx}"
|
||||
|
||||
|
||||
def get_lesson_url(course, lesson_number):
|
||||
if not lesson_number:
|
||||
return
|
||||
return f"/lms/courses/{course}/learn/{lesson_number}"
|
||||
return f"/courses/{course}/learn/{lesson_number}"
|
||||
|
||||
|
||||
def get_batch(course, batch_name):
|
||||
@@ -306,7 +310,7 @@ def get_progress(course, lesson, member=None):
|
||||
if not member:
|
||||
member = frappe.session.user
|
||||
|
||||
return frappe.db.get_value(
|
||||
return frappe.db.exists(
|
||||
"LMS Course Progress",
|
||||
{"course": course, "member": member, "lesson": lesson},
|
||||
["status"],
|
||||
@@ -379,7 +383,7 @@ def get_course_progress(course, member=None):
|
||||
return 0
|
||||
completed_lessons = frappe.db.count(
|
||||
"LMS Course Progress",
|
||||
{"course": course, "owner": member or frappe.session.user, "status": "Complete"},
|
||||
{"course": course, "member": member or frappe.session.user, "status": "Complete"},
|
||||
)
|
||||
precision = cint(frappe.db.get_default("float_precision")) or 3
|
||||
return flt(((completed_lessons / lesson_count) * 100), precision)
|
||||
@@ -636,37 +640,91 @@ def handle_notifications(doc, method):
|
||||
if topic.reference_doctype not in ["Course Lesson", "LMS Batch"]:
|
||||
return
|
||||
create_notification_log(doc, topic)
|
||||
notify_mentions(doc, topic)
|
||||
notify_mentions_on_portal(doc, topic)
|
||||
notify_mentions_via_email(doc, topic)
|
||||
|
||||
|
||||
def create_notification_log(doc, topic):
|
||||
course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course")
|
||||
instructors = frappe.db.get_all(
|
||||
"Course Instructor", {"parent": course}, pluck="instructor"
|
||||
)
|
||||
users = []
|
||||
if topic.reference_doctype == "LMS Course":
|
||||
course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course")
|
||||
course_title = frappe.db.get_value("LMS Course", course, "title")
|
||||
instructors = frappe.db.get_all(
|
||||
"Course Instructor", {"parent": course}, pluck="instructor"
|
||||
)
|
||||
users.append(topic.owner)
|
||||
users += instructors
|
||||
subject = _("New reply on the topic {0} in course {1}").format(
|
||||
topic.title, course_title
|
||||
)
|
||||
link = get_lesson_url(course, get_lesson_index(topic.reference_docname))
|
||||
|
||||
else:
|
||||
batch_title = frappe.db.get_value("LMS Batch", topic.reference_docname, "title")
|
||||
subject = _("New comment in batch {0}").format(batch_title)
|
||||
link = f"/batches/{topic.reference_docname}"
|
||||
moderators = frappe.get_all("Has Role", {"role": "Moderator"}, pluck="parent")
|
||||
users += moderators
|
||||
|
||||
notification = frappe._dict(
|
||||
{
|
||||
"subject": _("New reply on the topic {0}").format(topic.title),
|
||||
"subject": subject,
|
||||
"email_content": doc.reply,
|
||||
"document_type": topic.reference_doctype,
|
||||
"document_name": topic.reference_docname,
|
||||
"for_user": topic.owner,
|
||||
"from_user": doc.owner,
|
||||
"type": "Alert",
|
||||
"link": link,
|
||||
}
|
||||
)
|
||||
|
||||
users = []
|
||||
if doc.owner != topic.owner:
|
||||
users.append(topic.owner)
|
||||
|
||||
if doc.owner not in instructors:
|
||||
users += instructors
|
||||
make_notification_logs(notification, users)
|
||||
|
||||
|
||||
def notify_mentions(doc, topic):
|
||||
def notify_mentions_on_portal(doc, topic):
|
||||
mentions = extract_mentions(doc.reply)
|
||||
if not mentions:
|
||||
return
|
||||
|
||||
from_user_name = get_fullname(doc.owner)
|
||||
|
||||
if topic.reference_doctype == "LMS Course":
|
||||
course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course")
|
||||
subject = _("{0} mentioned you in a comment in {1}").format(
|
||||
from_user_name, topic.title
|
||||
)
|
||||
link = get_lesson_url(course, get_lesson_index(topic.reference_docname))
|
||||
else:
|
||||
batch_title = frappe.db.get_value("LMS Batch", topic.reference_docname, "title")
|
||||
subject = _("{0} mentioned you in a comment in {1}").format(
|
||||
from_user_name, batch_title
|
||||
)
|
||||
link = f"/batches/{topic.reference_docname}"
|
||||
|
||||
for user in mentions:
|
||||
notification = frappe._dict(
|
||||
{
|
||||
"subject": subject,
|
||||
"email_content": doc.reply,
|
||||
"document_type": topic.reference_doctype,
|
||||
"document_name": topic.reference_docname,
|
||||
"for_user": user,
|
||||
"from_user": doc.owner,
|
||||
"type": "Alert",
|
||||
"link": link,
|
||||
}
|
||||
)
|
||||
make_notification_logs(notification, user)
|
||||
|
||||
|
||||
def notify_mentions_via_email(doc, topic):
|
||||
outgoing_email_account = frappe.get_cached_value(
|
||||
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
|
||||
)
|
||||
if not outgoing_email_account or not frappe.conf.get("mail_login"):
|
||||
return
|
||||
|
||||
mentions = extract_mentions(doc.reply)
|
||||
if not mentions:
|
||||
return
|
||||
@@ -1208,6 +1266,7 @@ def get_course_details(course):
|
||||
"short_introduction",
|
||||
"published",
|
||||
"upcoming",
|
||||
"disable_self_learning",
|
||||
"published_on",
|
||||
"status",
|
||||
"paid_course",
|
||||
@@ -1299,7 +1358,7 @@ def get_categorized_courses(courses):
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_course_outline(course):
|
||||
def get_course_outline(course, progress=False):
|
||||
"""Returns the course outline."""
|
||||
outline = []
|
||||
chapters = frappe.get_all(
|
||||
@@ -1313,7 +1372,7 @@ def get_course_outline(course):
|
||||
as_dict=True,
|
||||
)
|
||||
chapter_details["idx"] = chapter.idx
|
||||
chapter_details.lessons = get_lessons(course, chapter_details)
|
||||
chapter_details.lessons = get_lessons(course, chapter_details, progress=progress)
|
||||
outline.append(chapter_details)
|
||||
return outline
|
||||
|
||||
@@ -1519,10 +1578,12 @@ def get_question_details(question):
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_batch_courses(batch):
|
||||
courses = []
|
||||
course_list = frappe.get_all("Batch Course", {"parent": batch}, pluck="course")
|
||||
course_list = frappe.get_all("Batch Course", {"parent": batch}, ["name", "course"])
|
||||
|
||||
for course in course_list:
|
||||
courses.append(get_course_details(course))
|
||||
details = get_course_details(course.course)
|
||||
details.batch_course = course.name
|
||||
courses.append(details)
|
||||
|
||||
return courses
|
||||
|
||||
@@ -1701,6 +1762,7 @@ def create_discussion_topic(doctype, docname):
|
||||
doc = frappe.new_doc("Discussion Topic")
|
||||
doc.update(
|
||||
{
|
||||
"title": docname,
|
||||
"reference_doctype": doctype,
|
||||
"reference_docname": docname,
|
||||
}
|
||||
@@ -1810,3 +1872,9 @@ def get_roles(name):
|
||||
"batch_evaluator": has_course_evaluator_role(name),
|
||||
"lms_student": has_student_role(name),
|
||||
}
|
||||
|
||||
|
||||
def publish_notifications(doc, method):
|
||||
frappe.publish_realtime(
|
||||
"publish_lms_notifications", user=doc.for_user, after_commit=True
|
||||
)
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
"label": "Enrollments"
|
||||
}
|
||||
],
|
||||
"content": "[{\"id\":\"jNO4sdKxHu\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Get Started</b></span>\",\"col\":12}},{\"id\":\"5s0qRBc4rY\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/courses\\\" draggable=\\\"false\\\">Visit LMS Portal</a>\",\"col\":4}},{\"id\":\"lGMuNLpmv-\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/courses/new-course/edit\\\">Create a Course</a>\",\"col\":4}},{\"id\":\"3TVyc9AkPy\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/app/web-page/new-web-page-1\\\">Setup a Home Page</a>\",\"col\":4}},{\"id\":\"9zcbqpu2gm\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/app/lms-settings/LMS%20Settings\\\">LMS Setting</a>\",\"col\":4}},{\"id\":\"0ATmnKmXjc\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"https://docs.frappelms.com\\\">Documentation</a>\",\"col\":4}},{\"id\":\"7tGB2TYPmn\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"https://frappe.school/courses/introducing-frappe-lms\\\">Video Tutorials</a>\",\"col\":4}},{\"id\":\"C128a4abjX\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"5q4sPiv2ci\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Signups\",\"col\":6}},{\"id\":\"8NSaRaEV5u\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Enrollments\",\"col\":6}},{\"id\":\"kMuzko0uAU\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"iuvIOHmztI\",\"type\":\"header\",\"data\":{\"text\":\"<span style=\\\"font-size: 18px;\\\"><b>Statistics</b></span>\",\"col\":12}},{\"id\":\"l0VTd66Uy2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Users\",\"col\":4}},{\"id\":\"wAWZin1KKk\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Course\",\"col\":4}},{\"id\":\"RLrIlFx0Hd\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Enrollments\",\"col\":4}},{\"id\":\"OuhWkhCQmq\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Course Completed\",\"col\":4}},{\"id\":\"3g8QmNqUXG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Certificate\",\"col\":4}},{\"id\":\"EZsdsujs8N\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Evaluation\",\"col\":4}},{\"id\":\"s-nfsFQbGV\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"jeOBWBzHEa\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Master</b></span>\",\"col\":12}},{\"id\":\"sVhgfS5GIh\",\"type\":\"card\",\"data\":{\"card_name\":\"Course Data\",\"col\":4}},{\"id\":\"Iea0snm4Fg\",\"type\":\"card\",\"data\":{\"card_name\":\"Course Stats\",\"col\":4}},{\"id\":\"bZB7RqOl6a\",\"type\":\"card\",\"data\":{\"card_name\":\"Certification\",\"col\":4}}]",
|
||||
"content": "[{\"id\":\"jNO4sdKxHu\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Get Started</b></span>\",\"col\":12}},{\"id\":\"5s0qRBc4rY\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/lms/courses\\\">Visit LMS Portal</a>\",\"col\":4}},{\"id\":\"lGMuNLpmv-\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/courses/new-course/edit\\\">Create a Course</a>\",\"col\":4}},{\"id\":\"3TVyc9AkPy\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/app/web-page/new-web-page-1\\\">Setup a Home Page</a>\",\"col\":4}},{\"id\":\"9zcbqpu2gm\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/app/lms-settings/LMS%20Settings\\\">LMS Setting</a>\",\"col\":4}},{\"id\":\"0ATmnKmXjc\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"https://docs.frappelms.com\\\">Documentation</a>\",\"col\":4}},{\"id\":\"7tGB2TYPmn\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"https://frappe.school/courses/introducing-frappe-lms\\\">Video Tutorials</a>\",\"col\":4}},{\"id\":\"C128a4abjX\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"5q4sPiv2ci\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Signups\",\"col\":6}},{\"id\":\"8NSaRaEV5u\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Enrollments\",\"col\":6}},{\"id\":\"kMuzko0uAU\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"iuvIOHmztI\",\"type\":\"header\",\"data\":{\"text\":\"<span style=\\\"font-size: 18px;\\\"><b>Statistics</b></span>\",\"col\":12}},{\"id\":\"l0VTd66Uy2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Users\",\"col\":4}},{\"id\":\"wAWZin1KKk\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Course\",\"col\":4}},{\"id\":\"RLrIlFx0Hd\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Enrollments\",\"col\":4}},{\"id\":\"OuhWkhCQmq\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Course Completed\",\"col\":4}},{\"id\":\"3g8QmNqUXG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Certificate\",\"col\":4}},{\"id\":\"EZsdsujs8N\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Evaluation\",\"col\":4}},{\"id\":\"s-nfsFQbGV\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"jeOBWBzHEa\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Master</b></span>\",\"col\":12}},{\"id\":\"sVhgfS5GIh\",\"type\":\"card\",\"data\":{\"card_name\":\"Course Data\",\"col\":4}},{\"id\":\"Iea0snm4Fg\",\"type\":\"card\",\"data\":{\"card_name\":\"Course Stats\",\"col\":4}},{\"id\":\"bZB7RqOl6a\",\"type\":\"card\",\"data\":{\"card_name\":\"Certification\",\"col\":4}}]",
|
||||
"creation": "2021-10-21 17:20:01.358903",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
"doctype": "Workspace",
|
||||
"hide_custom": 0,
|
||||
@@ -144,7 +145,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2023-05-11 15:41:25.514443",
|
||||
"modified": "2024-05-09 14:44:08.590606",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS",
|
||||
|
||||
@@ -107,3 +107,34 @@ def render_portal_page(path, **kwargs):
|
||||
frappe.form_dict.update(kwargs)
|
||||
page = TemplatePage(path)
|
||||
return page.render()
|
||||
|
||||
|
||||
class CoursePage(BaseRenderer):
|
||||
def __init__(self, path, http_status_code):
|
||||
super().__init__(path, http_status_code)
|
||||
self.renderer = None
|
||||
|
||||
def can_render(self):
|
||||
return self.path.startswith("course")
|
||||
|
||||
def render(self):
|
||||
if "learn" in self.path:
|
||||
prefix = self.path.split("/learn")[0]
|
||||
course_name = prefix.split("/")[1]
|
||||
lesson_index = self.path.split("/learn/")[1]
|
||||
chapter_number = lesson_index.split(".")[0]
|
||||
lesson_number = lesson_index.split(".")[1]
|
||||
|
||||
frappe.flags.redirect_location = (
|
||||
f"/lms/courses/{course_name}/learn/{chapter_number}-{lesson_number}"
|
||||
)
|
||||
return RedirectPage(self.path).render()
|
||||
|
||||
elif len(self.path.split("/")) > 1:
|
||||
course_name = self.path.split("/")[1]
|
||||
frappe.flags.redirect_location = f"/lms/courses/{course_name}"
|
||||
return RedirectPage(self.path).render()
|
||||
|
||||
else:
|
||||
frappe.flags.redirect_location = "/lms/courses"
|
||||
return RedirectPage(self.path).render()
|
||||
|
||||
@@ -86,4 +86,6 @@ lms.patches.v1_0.change_jobs_url #19-01-2024
|
||||
lms.patches.v1_0.custom_perm_for_discussions #14-01-2024
|
||||
lms.patches.v1_0.rename_evaluator_role
|
||||
lms.patches.v1_0.change_navbar_urls
|
||||
lms.patches.v1_0.set_published_on
|
||||
lms.patches.v1_0.set_published_on
|
||||
lms.patches.v2_0.fix_progress_percentage
|
||||
lms.patches.v2_0.add_discussion_topic_titles
|
||||
@@ -2,5 +2,7 @@ import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
if frappe.db.exists("Role", "Class Evaluator"):
|
||||
if frappe.db.exists("Role", "Class Evaluator") and not frappe.db.exists(
|
||||
"Role", "Batch Evaluator"
|
||||
):
|
||||
frappe.rename_doc("Role", "Class Evaluator", "Batch Evaluator")
|
||||
|
||||
13
lms/patches/v2_0/add_discussion_topic_titles.py
Normal file
13
lms/patches/v2_0/add_discussion_topic_titles.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
topics = frappe.get_all(
|
||||
"Discussion Topic",
|
||||
{"title": ["is", "not set"]},
|
||||
["name", "reference_docname", "title"],
|
||||
)
|
||||
|
||||
for topic in topics:
|
||||
if not topic.title:
|
||||
frappe.db.set_value("Discussion Topic", topic.name, "title", topic.reference_docname)
|
||||
10
lms/patches/v2_0/fix_progress_percentage.py
Normal file
10
lms/patches/v2_0/fix_progress_percentage.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import frappe
|
||||
from lms.lms.utils import get_course_progress
|
||||
|
||||
|
||||
def execute():
|
||||
enrollments = frappe.get_all("LMS Enrollment", fields=["name", "course", "member"])
|
||||
|
||||
for enrollment in enrollments:
|
||||
progress = get_course_progress(enrollment.course, enrollment.member)
|
||||
frappe.db.set_value("LMS Enrollment", enrollment.name, "progress", progress)
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/assets/lms/frontend/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Frappe UI App</title>
|
||||
<title>Frappe Learning</title>
|
||||
<meta name="title" content="{{ meta.title }}" />
|
||||
<meta name="image" content="{{ meta.image }}" />
|
||||
<meta name="description" content="{{ meta.description }}" />
|
||||
@@ -15,10 +15,10 @@
|
||||
<meta name="twitter:title" content="{{ meta.title }}" />
|
||||
<meta name="twitter:image" content="{{ meta.image }}" />
|
||||
<meta name="twitter:description" content="{{ meta.description }}" />
|
||||
<script type="module" crossorigin src="/assets/lms/frontend/assets/index-B0I4dIsL.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/lms/frontend/assets/frappe-ui-BlL1CpdE.js">
|
||||
<script type="module" crossorigin src="/assets/lms/frontend/assets/index-C-DogOtg.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/lms/frontend/assets/frappe-ui-CGsuCsfq.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/lms/frontend/assets/frappe-ui-B1gEXx4C.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/lms/frontend/assets/index-wBsCm0D8.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/lms/frontend/assets/index-C1pDkvO9.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import frappe
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_add_on_details(plan: str) -> dict[str, int]:
|
||||
"""
|
||||
Returns the number of courses and course members to be billed under add-ons for SAAS subscription
|
||||
"""
|
||||
|
||||
return {"courses": get_add_on_courses(plan), "members": get_add_on_members(plan)}
|
||||
|
||||
|
||||
def get_published_courses() -> int:
|
||||
return frappe.db.count("LMS Course", {"published": 1})
|
||||
|
||||
|
||||
def get_add_on_courses(plan: str) -> int:
|
||||
COURSE_LIMITS = {"Lite": 5, "Pro": 20}
|
||||
add_on_courses = 0
|
||||
courses_included_in_plans = COURSE_LIMITS.get(plan)
|
||||
|
||||
if courses_included_in_plans:
|
||||
published_courses = get_published_courses()
|
||||
add_on_courses = (
|
||||
published_courses - courses_included_in_plans
|
||||
if published_courses > courses_included_in_plans
|
||||
else 0
|
||||
)
|
||||
|
||||
return add_on_courses
|
||||
|
||||
|
||||
def get_add_on_members(plan: str) -> int:
|
||||
MEMBER_LIMITS = {"Lite": 500, "Pro": 1000}
|
||||
add_on_members = 0
|
||||
members_included_in_plans = MEMBER_LIMITS.get(plan)
|
||||
|
||||
if members_included_in_plans:
|
||||
active_members = get_members()
|
||||
add_on_members = (
|
||||
active_members - members_included_in_plans
|
||||
if active_members > members_included_in_plans
|
||||
else 0
|
||||
)
|
||||
|
||||
return add_on_members
|
||||
|
||||
|
||||
def get_members() -> int:
|
||||
return frappe.db.count("LMS Enrollment")
|
||||
@@ -30,7 +30,6 @@ def make_unsplash_request(path):
|
||||
import requests
|
||||
|
||||
url = f"{base_url}{path}"
|
||||
print(url)
|
||||
res = requests.get(
|
||||
url,
|
||||
headers={
|
||||
|
||||
@@ -32,6 +32,14 @@ def get_meta(app_path):
|
||||
}
|
||||
|
||||
if re.match(r"^courses/.*$", app_path):
|
||||
if "new/edit" in app_path:
|
||||
return {
|
||||
"title": _("New Course"),
|
||||
"image": frappe.db.get_single_value("Website Settings", "banner_image"),
|
||||
"description": "Create a new course",
|
||||
"keywords": "New Course, Create Course",
|
||||
"link": "/lms/courses/new/edit",
|
||||
}
|
||||
course_name = app_path.split("/")[1]
|
||||
course = frappe.db.get_value(
|
||||
"LMS Course",
|
||||
@@ -56,7 +64,6 @@ def get_meta(app_path):
|
||||
"link": "/batches",
|
||||
}
|
||||
if re.match(r"^batches/details/.*$", app_path):
|
||||
print(app_path, "app_path")
|
||||
batch_name = app_path.split("/")[2]
|
||||
batch = frappe.db.get_value(
|
||||
"LMS Batch",
|
||||
@@ -90,7 +97,7 @@ def get_meta(app_path):
|
||||
as_dict=True,
|
||||
)
|
||||
return {
|
||||
"title": job_opening.title,
|
||||
"title": job_opening.job_title,
|
||||
"image": job_opening.company_logo,
|
||||
"description": job_opening.company_name,
|
||||
"keywords": "Job Openings, Jobs, Vacancies",
|
||||
|
||||
@@ -22,5 +22,9 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/frappe/lms/issues"
|
||||
},
|
||||
"homepage": "https://github.com/frappe/lms#readme"
|
||||
}
|
||||
"homepage": "https://github.com/frappe/lms#readme",
|
||||
"devDependencies": {
|
||||
"cypress": "^13.9.0",
|
||||
"cypress-file-upload": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user