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
|
run: pip install semgrep
|
||||||
|
|
||||||
- name: Run Semgrep rules
|
- 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", () => {
|
describe("Course Creation", () => {
|
||||||
it("creates a new course", () => {
|
it("creates a new course", () => {
|
||||||
cy.login();
|
cy.login();
|
||||||
cy.visit("/courses");
|
cy.wait(1000);
|
||||||
|
|
||||||
|
cy.visit("/lms/courses");
|
||||||
|
|
||||||
// Create a course
|
// Create a course
|
||||||
cy.get("a.btn").contains("Create a Course").click();
|
cy.get("a").contains("New 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.wait(1000);
|
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();
|
cy.button("Save").click();
|
||||||
|
|
||||||
// Add Chapter
|
// Add Chapter
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.link("Course Outline").click();
|
cy.button("Add Chapter").click();
|
||||||
|
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.get(".edit-header .btn-add-chapter").click();
|
cy.get("[id^=headlessui-dialog-panel-")
|
||||||
cy.wait(500);
|
.should("be.visible")
|
||||||
cy.get("#chapter-title").type("Test Chapter");
|
.within(() => {
|
||||||
cy.get("#chapter-description").type("Test Chapter Description");
|
cy.get("label").contains("Title").type("Test Chapter");
|
||||||
cy.button("Save").click();
|
cy.button("Add Chapter").click();
|
||||||
|
});
|
||||||
|
|
||||||
// Add Lesson
|
// Add Lesson
|
||||||
cy.wait(1000);
|
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.wait(1000);
|
||||||
cy.get("#lesson-title").type("Test Lesson");
|
|
||||||
|
|
||||||
// Content
|
cy.get("label").contains("Title").type("Test Lesson");
|
||||||
cy.get(".collapse-section.collapsed:first").click();
|
/* cy.get("#content .ce-block")
|
||||||
cy.get("#lesson-content .ce-block")
|
|
||||||
.click()
|
.click()
|
||||||
.type(
|
.invoke("text", "https://www.youtube.com/watch?v=GoDtyItReto"); */
|
||||||
"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("#content .ce-block")
|
||||||
);
|
.click()
|
||||||
cy.get("#lesson-content .ce-toolbar__plus").click();
|
.paste("https://www.youtube.com/watch?v=GoDtyItReto"); */
|
||||||
cy.get('#lesson-content [data-item-name="youtube"]').click();
|
|
||||||
cy.get('input[data-fieldname="youtube"]').type("GoDtyItReto");
|
cy.fixture("Youtube.mov", "base64").then((fileContent) => {
|
||||||
cy.button("Insert").click();
|
cy.get('input[type="file"]').attachFile({
|
||||||
cy.wait(1000);
|
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();
|
cy.button("Save").click();
|
||||||
|
|
||||||
// View Course
|
// View Course
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.visit("/courses");
|
cy.visit("/lms");
|
||||||
cy.get(".course-card-title:first").contains("Test Course");
|
cy.wait(500);
|
||||||
cy.get(".course-card:first").click();
|
cy.url().should("include", "/lms/courses");
|
||||||
cy.url().should("include", "/courses/test-course");
|
cy.get(".grid a:first").within(() => {
|
||||||
cy.get("#title").contains("Test Course");
|
cy.get("div").contains("Test Course");
|
||||||
cy.get(".preview-video").should(
|
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",
|
"have.attr",
|
||||||
"src",
|
"src",
|
||||||
"https://www.youtube.com/embed/-LPmw2Znl2c"
|
"https://www.youtube.com/embed/-LPmw2Znl2c"
|
||||||
);
|
);
|
||||||
cy.get("#intro").contains("Test Course Short Introduction");
|
|
||||||
|
|
||||||
// View Chapter
|
// View Chapter
|
||||||
cy.get(".chapter-title-main:first").contains("Test Chapter");
|
cy.get("div").contains("Test Chapter");
|
||||||
cy.get(".chapter-description:first").contains(
|
cy.get("[id^=headlessui-disclosure-panel-").within(() => {
|
||||||
"Test Chapter Description"
|
cy.get("div").contains("Test Lesson").click();
|
||||||
);
|
});
|
||||||
cy.get(".lesson-info:first").contains("Test Lesson");
|
cy.wait(1000);
|
||||||
cy.get(".lesson-info:first").click();
|
|
||||||
|
|
||||||
// View Lesson
|
// View Lesson
|
||||||
cy.wait(1000);
|
cy.url().should("include", "/learn/1-1");
|
||||||
cy.url().should("include", "learn/1.1");
|
cy.get("div").contains("Test Lesson");
|
||||||
cy.get("#title").contains("Test Lesson");
|
cy.get("div").contains(
|
||||||
cy.get(".lesson-video iframe").should(
|
"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. "
|
||||||
"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.get("video")
|
||||||
|
.should("be.visible")
|
||||||
|
.children("source")
|
||||||
|
.invoke("attr", "src")
|
||||||
|
.should("include", "/files/Youtube");
|
||||||
|
|
||||||
// Add Discussion
|
// Add Discussion
|
||||||
cy.get(".reply").click();
|
cy.button("New Question").click();
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
cy.get(".discussion-modal").should("be.visible");
|
cy.get("[id^=headlessui-dialog-panel-").within(() => {
|
||||||
|
cy.get("label").contains("Title").type("Test Discussion");
|
||||||
// Enter title
|
cy.get("div[contenteditable=true]").invoke(
|
||||||
cy.get(".modal .topic-title")
|
"text",
|
||||||
.type("Discussion from tests")
|
"This is a test discussion. This will check if the UI is working properly."
|
||||||
.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.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 --
|
// -- This will overwrite an existing command --
|
||||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||||
|
|
||||||
|
import "cypress-file-upload";
|
||||||
|
|
||||||
Cypress.Commands.add("login", (email, password) => {
|
Cypress.Commands.add("login", (email, password) => {
|
||||||
if (!email) {
|
if (!email) {
|
||||||
email = Cypress.config("testUser") || "Administrator";
|
email = Cypress.config("testUser") || "Administrator";
|
||||||
@@ -53,3 +55,13 @@ Cypress.Commands.add("iconButton", (text) => {
|
|||||||
Cypress.Commands.add("dialog", (selector) => {
|
Cypress.Commands.add("dialog", (selector) => {
|
||||||
return cy.get(`[role=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",
|
"chart.js": "^4.4.1",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"frappe-ui": "^0.1.50",
|
"frappe-ui": "^0.1.56",
|
||||||
"lucide-vue-next": "^0.309.0",
|
"lucide-vue-next": "^0.309.0",
|
||||||
"markdown-it": "^14.0.0",
|
"markdown-it": "^14.0.0",
|
||||||
"pinia": "^2.0.33",
|
"pinia": "^2.0.33",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
|
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
|
||||||
<div class="flex flex-col overflow-y-auto">
|
<div class="flex flex-col overflow-y-auto">
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
v-for="link in links"
|
v-for="link in sidebarLinks"
|
||||||
:link="link"
|
:link="link"
|
||||||
:isCollapsed="isSidebarCollapsed"
|
:isCollapsed="isSidebarCollapsed"
|
||||||
class="mx-2 my-0.5"
|
class="mx-2 my-0.5"
|
||||||
@@ -42,10 +42,53 @@ import UserDropdown from '@/components/UserDropdown.vue'
|
|||||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||||
import SidebarLink from '@/components/SidebarLink.vue'
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted, inject, computed } from 'vue'
|
||||||
import { getSidebarLinks } from '../utils'
|
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 = () => {
|
const getSidebarFromStorage = () => {
|
||||||
return useStorage('sidebar_is_collapsed', false)
|
return useStorage('sidebar_is_collapsed', false)
|
||||||
|
|||||||
@@ -19,8 +19,14 @@
|
|||||||
<ListView
|
<ListView
|
||||||
:columns="getCoursesColumns()"
|
:columns="getCoursesColumns()"
|
||||||
:rows="courses.data"
|
:rows="courses.data"
|
||||||
row-key="name"
|
row-key="batch_course"
|
||||||
:options="{ showTooltip: false }"
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
getRowRoute: (row) => ({
|
||||||
|
name: 'CourseDetail',
|
||||||
|
params: { courseName: row.name },
|
||||||
|
}),
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<ListHeader
|
<ListHeader
|
||||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
@@ -49,7 +55,10 @@
|
|||||||
<ListSelectBanner>
|
<ListSelectBanner>
|
||||||
<template #actions="{ unselectAll, selections }">
|
<template #actions="{ unselectAll, selections }">
|
||||||
<div class="flex gap-2">
|
<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" />
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,11 +142,13 @@ const removeCourse = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeCourses = (selections) => {
|
const removeCourses = (selections, unselectAll) => {
|
||||||
selections.forEach(async (course) => {
|
selections.forEach(async (course) => {
|
||||||
removeCourse.submit({ course })
|
removeCourse.submit({ course })
|
||||||
await setTimeout(1000)
|
|
||||||
})
|
})
|
||||||
courses.reload()
|
setTimeout(() => {
|
||||||
|
courses.reload()
|
||||||
|
unselectAll()
|
||||||
|
}, 1000)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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
|
<Badge
|
||||||
v-if="batch.data.seat_count && seats_left > 0"
|
v-if="batch.data.seat_count && seats_left > 0"
|
||||||
theme="green"
|
theme="green"
|
||||||
|
|||||||
@@ -52,7 +52,10 @@
|
|||||||
<ListSelectBanner>
|
<ListSelectBanner>
|
||||||
<template #actions="{ unselectAll, selections }">
|
<template #actions="{ unselectAll, selections }">
|
||||||
<div class="flex gap-2">
|
<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" />
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,11 +145,13 @@ const removeStudent = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeStudents = (selections) => {
|
const removeStudents = (selections, unselectAll) => {
|
||||||
selections.forEach(async (student) => {
|
selections.forEach(async (student) => {
|
||||||
removeStudent.submit({ student })
|
removeStudent.submit({ student })
|
||||||
await setTimeout(1000)
|
|
||||||
})
|
})
|
||||||
students.reload()
|
setTimeout(() => {
|
||||||
|
students.reload()
|
||||||
|
unselectAll()
|
||||||
|
}, 500)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div
|
<div
|
||||||
class="course-image"
|
class="course-image"
|
||||||
:class="{ 'default-image': !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">
|
<div class="flex relative top-4 left-4 w-fit flex-wrap">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -147,8 +147,8 @@ const props = defineProps({
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: theme('colors.orange.100');
|
background-color: theme('colors.green.100');
|
||||||
color: theme('colors.orange.600');
|
color: theme('colors.green.600');
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-group {
|
.avatar-group {
|
||||||
|
|||||||
@@ -46,6 +46,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</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
|
<Button
|
||||||
v-else
|
v-else
|
||||||
@click="enrollStudent()"
|
@click="enrollStudent()"
|
||||||
@@ -135,7 +141,6 @@ function enrollStudent() {
|
|||||||
const enrollStudentResource = createResource({
|
const enrollStudentResource = createResource({
|
||||||
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
||||||
})
|
})
|
||||||
console.log(props.course)
|
|
||||||
enrollStudentResource
|
enrollStudentResource
|
||||||
.submit({
|
.submit({
|
||||||
course: props.course.data.name,
|
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>
|
</script>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="text-base">
|
<div class="text-base">
|
||||||
<div
|
<div
|
||||||
v-if="title && (outline.data?.length || allowEdit)"
|
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">
|
<div class="font-semibold text-lg">
|
||||||
{{ __(title) }}
|
{{ __(title) }}
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
{{ lesson.title }}
|
{{ lesson.title }}
|
||||||
<Check
|
<Check
|
||||||
v-if="lesson.is_complete"
|
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>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, createResource } from 'frappe-ui'
|
import { Button, createResource } from 'frappe-ui'
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -139,6 +139,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
getProgress: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const outline = createResource({
|
const outline = createResource({
|
||||||
@@ -146,6 +150,7 @@ const outline = createResource({
|
|||||||
cache: ['course_outline', props.courseName],
|
cache: ['course_outline', props.courseName],
|
||||||
params: {
|
params: {
|
||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
|
progress: props.getProgress,
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
<div
|
<div
|
||||||
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
|
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
|
||||||
>
|
>
|
||||||
<slot name="sidebar" />
|
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full overflow-auto" id="scrollContainer">
|
<div class="w-full overflow-auto" id="scrollContainer">
|
||||||
|
|||||||
@@ -69,9 +69,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextEditor
|
<TextEditor
|
||||||
class="mt-5"
|
class="mt-5"
|
||||||
:content="newReply"
|
:content="newReply"
|
||||||
|
:mentions="mentionUsers"
|
||||||
@change="(val) => (newReply = val)"
|
@change="(val) => (newReply = val)"
|
||||||
placeholder="Type your reply here..."
|
placeholder="Type your reply here..."
|
||||||
:fixedMenu="true"
|
:fixedMenu="true"
|
||||||
@@ -92,13 +94,14 @@ import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
|
|||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '../utils'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||||
import { ref, inject, onMounted } from 'vue'
|
import { ref, inject, onMounted, computed } from 'vue'
|
||||||
import { createToast } from '../utils'
|
import { createToast } from '../utils'
|
||||||
|
|
||||||
const showTopics = defineModel('showTopics')
|
const showTopics = defineModel('showTopics')
|
||||||
const newReply = ref('')
|
const newReply = ref('')
|
||||||
const socket = inject('$socket')
|
const socket = inject('$socket')
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const allUsers = inject('$allUsers')
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
topic: {
|
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 = () => {
|
const postReply = () => {
|
||||||
newReplyResource.submit(
|
newReplyResource.submit(
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -42,14 +42,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
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" />
|
<MessageSquareText class="w-7 h-7 text-gray-500 stroke-1.5 mr-2" />
|
||||||
<div>
|
<div class="">
|
||||||
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
||||||
{{ __(emptyStateTitle) }}
|
{{ __(emptyStateTitle) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="">
|
<div class="text-gray-600">
|
||||||
{{ __(emptyStateText) }}
|
{{ __(emptyStateText) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,13 +63,14 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Button, TextEditor } from 'frappe-ui'
|
import { createResource, Button } from 'frappe-ui'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '../utils'
|
||||||
import { ref, onMounted, inject } from 'vue'
|
import { ref, onMounted, inject } from 'vue'
|
||||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||||
import DiscussionModal from '@/components/Modals/DiscussionModal.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 showTopics = ref(true)
|
||||||
const currentTopic = ref(null)
|
const currentTopic = ref(null)
|
||||||
@@ -96,12 +97,16 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
emptyStateText: {
|
emptyStateText: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Be the first to start a discussion',
|
default: 'Start a discussion',
|
||||||
},
|
},
|
||||||
singleThread: {
|
singleThread: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
scrollToBottom: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -110,8 +115,19 @@ onMounted(() => {
|
|||||||
socket.on('new_discussion_topic', (data) => {
|
socket.on('new_discussion_topic', (data) => {
|
||||||
topics.refresh()
|
topics.refresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (props.scrollToBottom) {
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToEnd()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const scrollToEnd = () => {
|
||||||
|
let scrollContainer = getScrollContainer()
|
||||||
|
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
const topics = createResource({
|
const topics = createResource({
|
||||||
url: 'lms.lms.utils.get_discussion_topics',
|
url: 'lms.lms.utils.get_discussion_topics',
|
||||||
cache: ['topics', props.doctype, props.docname],
|
cache: ['topics', props.doctype, props.docname],
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
{{
|
{{
|
||||||
uploading
|
uploading
|
||||||
? __('Uploading {0}%').format(progress)
|
? __('Uploading {0}%').format(progress)
|
||||||
: __('Upload an File')
|
: __('Upload a File')
|
||||||
}}
|
}}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,15 +29,42 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { getSidebarLinks } from '../utils'
|
import { getSidebarLinks } from '../utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { computed, inject } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { usersStore } from '@/stores/user'
|
||||||
|
import { LogOut, LogIn, UserRound } from 'lucide-vue-next'
|
||||||
|
|
||||||
const { logout, user } = sessionStore()
|
const { logout, user } = sessionStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
let { userResource } = usersStore()
|
||||||
|
|
||||||
const tabs = computed(() => {
|
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) => {
|
let isActive = (tab) => {
|
||||||
@@ -50,6 +77,13 @@ const handleClick = (tab) => {
|
|||||||
logout.submit().then(() => {
|
logout.submit().then(() => {
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
})
|
})
|
||||||
|
else if (tab.label == 'Profile')
|
||||||
|
router.push({
|
||||||
|
name: 'Profile',
|
||||||
|
params: {
|
||||||
|
username: userResource.data?.username,
|
||||||
|
},
|
||||||
|
})
|
||||||
else router.push({ name: tab.to })
|
else router.push({ name: tab.to })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
size: '2xl',
|
size: '2xl',
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: 'Submit',
|
label: 'Post',
|
||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
onClick: (close) => submitTopic(close),
|
onClick: (close) => submitTopic(close),
|
||||||
},
|
},
|
||||||
@@ -15,10 +15,7 @@
|
|||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
<FormControl v-model="topic.title" :label="__('Title')" type="text" />
|
||||||
{{ __('Title') }}
|
|
||||||
</div>
|
|
||||||
<Input type="text" v-model="topic.title" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
@@ -37,8 +34,9 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
|
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
|
||||||
import { reactive, defineModel } from 'vue'
|
import { reactive, defineModel, computed } from 'vue'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
|
||||||
const topics = defineModel('reloadTopics')
|
const topics = defineModel('reloadTopics')
|
||||||
|
|
||||||
@@ -93,6 +91,14 @@ const submitTopic = (close) => {
|
|||||||
topicResource.submit(
|
topicResource.submit(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
|
validate() {
|
||||||
|
if (!topic.title) {
|
||||||
|
return 'Title cannot be empty.'
|
||||||
|
}
|
||||||
|
if (!topic.reply) {
|
||||||
|
return 'Reply cannot be empty.'
|
||||||
|
}
|
||||||
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
replyResource.submit(
|
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) {
|
if (data.shuffle_questions) {
|
||||||
data.questions = data.questions.sort(() => Math.random() - 0.5)
|
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({
|
const quizSubmission = createResource({
|
||||||
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
|
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -315,7 +323,6 @@ watch(activeQuestion, (value) => {
|
|||||||
watch(
|
watch(
|
||||||
() => props.quizName,
|
() => props.quizName,
|
||||||
(newName) => {
|
(newName) => {
|
||||||
console.log(newName)
|
|
||||||
if (newName) {
|
if (newName) {
|
||||||
quiz.reload()
|
quiz.reload()
|
||||||
}
|
}
|
||||||
@@ -384,7 +391,7 @@ const addToLocalStorage = () => {
|
|||||||
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
||||||
let questionData = {
|
let questionData = {
|
||||||
question_index: activeQuestion.value,
|
question_index: activeQuestion.value,
|
||||||
answers: getAnswers().join(),
|
answer: getAnswers().join(),
|
||||||
is_correct: showAnswers.filter((answer) => {
|
is_correct: showAnswers.filter((answer) => {
|
||||||
return answer != undefined
|
return answer != undefined
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<div
|
<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'"
|
:class="isCollapsed ? 'p-1' : 'px-2 py-1'"
|
||||||
>
|
>
|
||||||
<Tooltip :text="link.label" placement="right">
|
<Tooltip :text="link.label" placement="right">
|
||||||
@@ -29,6 +29,9 @@
|
|||||||
>
|
>
|
||||||
{{ link.label }}
|
{{ link.label }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
|
||||||
|
{{ link.count }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ const props = defineProps({
|
|||||||
default: 'Tags',
|
default: 'Tags',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
console.log(props.modelValue)
|
|
||||||
let tags = ref(props.modelValue)
|
let tags = ref(props.modelValue)
|
||||||
console.log(tags.value)
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
let newTag = ref('')
|
let newTag = ref('')
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,12 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="text-base font-medium text-gray-900 leading-none">
|
<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 }}
|
{{ branding.data?.brand_name }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else> Learning </span>
|
<span v-else> Learning </span>
|
||||||
@@ -57,13 +62,23 @@
|
|||||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { Dropdown, createResource } from 'frappe-ui'
|
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 { useRouter } from 'vue-router'
|
||||||
import { convertToTitleCase } from '../utils'
|
import { convertToTitleCase } from '../utils'
|
||||||
import { onMounted, inject } from 'vue'
|
import { onMounted, inject } from 'vue'
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { logout } = sessionStore()
|
||||||
|
let { userResource } = usersStore()
|
||||||
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isCollapsed: {
|
isCollapsed: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -80,10 +95,6 @@ const branding = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const { logout } = sessionStore()
|
|
||||||
let { userResource } = usersStore()
|
|
||||||
|
|
||||||
let { isLoggedIn } = sessionStore()
|
|
||||||
const userDropdownOptions = [
|
const userDropdownOptions = [
|
||||||
{
|
{
|
||||||
icon: User,
|
icon: User,
|
||||||
@@ -95,6 +106,19 @@ const userDropdownOptions = [
|
|||||||
return isLoggedIn
|
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,
|
icon: LogOut,
|
||||||
label: 'Log out',
|
label: 'Log out',
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ app.provide('$dayjs', dayjs)
|
|||||||
app.provide('$socket', initSocket())
|
app.provide('$socket', initSocket())
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
const { userResource } = usersStore()
|
const { userResource, allUsers } = usersStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
app.provide('$user', userResource)
|
app.provide('$user', userResource)
|
||||||
|
app.provide('$allUsers', allUsers)
|
||||||
app.config.globalProperties.$user = userResource
|
app.config.globalProperties.$user = userResource
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</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">
|
<div class="border-r-2">
|
||||||
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-x-visible">
|
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-x-visible">
|
||||||
<template #tab="{ tab, selected }" class="overflow-x-hidden">
|
<template #tab="{ tab, selected }" class="overflow-x-hidden">
|
||||||
@@ -66,9 +66,10 @@
|
|||||||
<Discussions
|
<Discussions
|
||||||
doctype="LMS Batch"
|
doctype="LMS Batch"
|
||||||
:docname="batch.data.name"
|
:docname="batch.data.name"
|
||||||
title="Discussions"
|
:title="__('Discussions')"
|
||||||
:key="batch.data.name"
|
:key="batch.data.name"
|
||||||
:singleThread="true"
|
:singleThread="true"
|
||||||
|
:scrollToBottom="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ const newBatch = createResource({
|
|||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'LMS Batch',
|
doctype: 'LMS Batch',
|
||||||
meta_image: batch.image.file_url,
|
meta_image: batch.image?.file_url,
|
||||||
...batch,
|
...batch,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -279,7 +279,7 @@ const editBatch = createResource({
|
|||||||
doctype: 'LMS Batch',
|
doctype: 'LMS Batch',
|
||||||
name: props.batchName,
|
name: props.batchName,
|
||||||
fieldname: {
|
fieldname: {
|
||||||
meta_image: batch.image.file_url,
|
meta_image: batch.image?.file_url,
|
||||||
...batch,
|
...batch,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,23 @@
|
|||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
{{ batch.data.description }}
|
{{ batch.data.description }}
|
||||||
</div>
|
</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">
|
<div class="flex items-center">
|
||||||
<BookOpen class="h-4 w-4 text-gray-700 mr-2" />
|
<BookOpen class="h-4 w-4 text-gray-700 mr-2" />
|
||||||
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
|
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="batch.data.courses">·</span>
|
<span class="hidden lg:block" v-if="batch.data.courses"
|
||||||
|
>·</span
|
||||||
|
>
|
||||||
<DateRange
|
<DateRange
|
||||||
:startDate="batch.data.start_date"
|
:startDate="batch.data.start_date"
|
||||||
:endDate="batch.data.end_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">
|
<div class="flex items-center">
|
||||||
<Clock class="h-4 w-4 text-gray-700 mr-2" />
|
<Clock class="h-4 w-4 text-gray-700 mr-2" />
|
||||||
<span>
|
<span>
|
||||||
@@ -31,14 +37,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-[60%,20%] gap-20 mt-10">
|
<div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
|
||||||
<div class="">
|
<div class="order-2 lg:order-none">
|
||||||
<div
|
<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"
|
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"
|
v-html="batch.data.batch_details"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="order-1 lg:order-none">
|
||||||
<BatchOverlay :batch="batch" />
|
<BatchOverlay :batch="batch" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,7 +54,7 @@
|
|||||||
{{ __('Courses') }}
|
{{ __('Courses') }}
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
v-if="batch.data.courses"
|
v-if="batch.data.courses"
|
||||||
v-for="course in courses.data"
|
v-for="course in courses.data"
|
||||||
@@ -79,7 +85,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, inject } from 'vue'
|
import { computed, inject } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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 { formatTime } from '@/utils'
|
||||||
import { Breadcrumbs, createResource } from 'frappe-ui'
|
import { Breadcrumbs, createResource } from 'frappe-ui'
|
||||||
import CourseCard from '@/components/CourseCard.vue'
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
/>
|
/>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<router-link
|
<router-link
|
||||||
v-if="user.data"
|
v-if="user.data?.is_moderator"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'BatchCreation',
|
name: 'BatchCreation',
|
||||||
params: { batchName: 'new' },
|
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"
|
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
>
|
>
|
||||||
<Breadcrumbs :items="breadcrumbs" />
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
<div>
|
||||||
|
<FormControl
|
||||||
|
type="text"
|
||||||
|
placeholder="Search Participants"
|
||||||
|
v-model="searchQuery"
|
||||||
|
@input="participants.reload()"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Search class="w-4" name="search" />
|
||||||
|
</template>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
</header>
|
</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
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
@@ -38,14 +51,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Breadcrumbs, createResource } from 'frappe-ui'
|
import { Breadcrumbs, FormControl, createResource } from 'frappe-ui'
|
||||||
import { computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import { Search } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
const participants = createResource({
|
const participants = createResource({
|
||||||
url: 'lms.lms.api.get_certified_participants',
|
url: 'lms.lms.api.get_certified_participants',
|
||||||
|
method: 'GET',
|
||||||
|
cache: ['certified_participants'],
|
||||||
|
makeParams() {
|
||||||
|
return {
|
||||||
|
search_query: searchQuery.value,
|
||||||
|
}
|
||||||
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
cache: ['certified-participants'],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
|
|||||||
@@ -149,6 +149,12 @@ updateDocumentTitle(pageMeta)
|
|||||||
padding: revert;
|
padding: revert;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.course-description ul {
|
||||||
|
list-style: disc;
|
||||||
|
margin: revert;
|
||||||
|
padding: revert;
|
||||||
|
}
|
||||||
|
|
||||||
.avatar-group {
|
.avatar-group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -65,8 +65,8 @@
|
|||||||
name: 'Lesson',
|
name: 'Lesson',
|
||||||
params: {
|
params: {
|
||||||
courseName: course.name,
|
courseName: course.name,
|
||||||
chapterNumber: course.current_lesson.split('.')[0],
|
chapterNumber: course.current_lesson.split('-')[0],
|
||||||
lessonNumber: course.current_lesson.split('.')[1],
|
lessonNumber: course.current_lesson.split('-')[1],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: course.membership
|
: course.membership
|
||||||
|
|||||||
@@ -114,7 +114,11 @@
|
|||||||
@click="removeTag(tag)"
|
@click="removeTag(tag)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FormControl v-model="newTag" @keyup.enter="updateTags()" />
|
<FormControl
|
||||||
|
v-model="newTag"
|
||||||
|
@keyup.enter="updateTags()"
|
||||||
|
id="tags"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +126,10 @@
|
|||||||
<div class="text-lg font-semibold mt-5 mb-4">
|
<div class="text-lg font-semibold mt-5 mb-4">
|
||||||
{{ __('Settings') }}
|
{{ __('Settings') }}
|
||||||
</div>
|
</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
|
<FormControl
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="course.published"
|
v-model="course.published"
|
||||||
@@ -139,6 +146,12 @@
|
|||||||
:label="__('Disable Self Enrollment')"
|
:label="__('Disable Self Enrollment')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-model="course.published_on"
|
||||||
|
:label="__('Published On')"
|
||||||
|
type="date"
|
||||||
|
class="mb-5"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="container border-t">
|
<div class="container border-t">
|
||||||
<div class="text-lg font-semibold mt-5 mb-4">
|
<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 user = inject('$user')
|
||||||
const newTag = ref('')
|
const newTag = ref('')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const instructors = ref([])
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -212,6 +226,7 @@ const course = reactive({
|
|||||||
course_image: null,
|
course_image: null,
|
||||||
tags: '',
|
tags: '',
|
||||||
published: false,
|
published: false,
|
||||||
|
published_on: '',
|
||||||
upcoming: false,
|
upcoming: false,
|
||||||
disable_self_learning: false,
|
disable_self_learning: false,
|
||||||
paid_course: false,
|
paid_course: false,
|
||||||
@@ -220,9 +235,14 @@ const course = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
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' })
|
router.push({ name: 'Courses' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.courseName !== 'new') {
|
if (props.courseName !== 'new') {
|
||||||
courseResource.reload()
|
courseResource.reload()
|
||||||
}
|
}
|
||||||
@@ -234,7 +254,7 @@ const courseCreationResource = createResource({
|
|||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'LMS Course',
|
doctype: 'LMS Course',
|
||||||
image: course.course_image.file_url,
|
image: course.course_image?.file_url || '',
|
||||||
...values,
|
...values,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -249,7 +269,7 @@ const courseEditResource = createResource({
|
|||||||
doctype: 'LMS Course',
|
doctype: 'LMS Course',
|
||||||
name: values.course,
|
name: values.course,
|
||||||
fieldname: {
|
fieldname: {
|
||||||
image: course.course_image.file_url,
|
image: course.course_image?.file_url || '',
|
||||||
...course,
|
...course,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -281,6 +301,8 @@ const courseResource = createResource({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.image) imageResource.reload({ image: data.image })
|
if (data.image) imageResource.reload({ image: data.image })
|
||||||
|
instructors.value = data.instructors
|
||||||
|
check_permission()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -358,7 +380,7 @@ watch(
|
|||||||
|
|
||||||
const validateFile = (file) => {
|
const validateFile = (file) => {
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
let extension = file.name.split('.').pop().toLowerCase()
|
||||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
|
||||||
return 'Only image file is allowed.'
|
return 'Only image file is allowed.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,6 +408,21 @@ const removeImage = () => {
|
|||||||
course.course_image = null
|
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(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let crumbs = [
|
let crumbs = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<div
|
<div
|
||||||
v-show="openInstructorEditor"
|
v-show="openInstructorEditor"
|
||||||
id="instructor-notes"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +52,10 @@
|
|||||||
<label class="block font-medium text-gray-600 mb-1">
|
<label class="block font-medium text-gray-600 mb-1">
|
||||||
{{ __('Content') }}
|
{{ __('Content') }}
|
||||||
</label>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,12 +18,16 @@
|
|||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<router-link
|
<router-link
|
||||||
|
v-if="user.data"
|
||||||
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
|
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
|
||||||
>
|
>
|
||||||
<Button variant="solid">
|
<Button variant="solid">
|
||||||
{{ __('Start Learning') }}
|
{{ __('Start Learning') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<Button v-else @click="redirectToLogin()">
|
||||||
|
{{ __('Login') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="border-r container pt-5 pb-10 px-5">
|
<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">
|
<div class="flex flex-col md:flex-row md:items-center justify-between">
|
||||||
@@ -151,7 +155,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sticky top-10">
|
<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">
|
<div class="text-lg font-semibold">
|
||||||
{{ lesson.data.course_title }}
|
{{ lesson.data.course_title }}
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +174,11 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CourseOutline :courseName="courseName" :key="chapterNumber" />
|
<CourseOutline
|
||||||
|
:courseName="courseName"
|
||||||
|
:key="chapterNumber"
|
||||||
|
:getProgress="lesson.data.membership ? true : false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,6 +336,10 @@ const allowInstructorContent = () => {
|
|||||||
if (lesson.data?.instructors.includes(user.data?.name)) return true
|
if (lesson.data?.instructors.includes(user.data?.name)) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const redirectToLogin = () => {
|
||||||
|
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.avatar-group {
|
.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>
|
</EditCoverImage>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mx-auto -mt-4 max-w-4xl translate-x-0 sm:px-5">
|
<div class="mx-auto -mt-10 md:-mt-4 max-w-4xl translate-x-0 px-5">
|
||||||
<div class="flex items-center">
|
<div class="flex flex-col md:flex-row items-center">
|
||||||
<div>
|
<div>
|
||||||
<img
|
<img
|
||||||
v-if="profile.data.user_image"
|
v-if="profile.data.user_image"
|
||||||
@@ -57,7 +57,11 @@
|
|||||||
{{ profile.data.headline }}
|
{{ profile.data.headline }}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<template #prefix>
|
||||||
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
|
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mt-7">
|
<div class="mt-7 mb-10">
|
||||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||||
{{ __('About') }}
|
{{ __('About') }}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -12,12 +12,92 @@
|
|||||||
{{ __('No introduction') }}
|
{{ __('No introduction') }}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
<script setup>
|
<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({
|
const props = defineProps({
|
||||||
profile: {
|
profile: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mt-7">
|
<div class="mt-7 mb-10">
|
||||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||||
{{ __('Certificates') }}
|
{{ __('Certificates') }}
|
||||||
</h2>
|
</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
|
<div
|
||||||
v-for="certificate in certificates.data"
|
v-for="certificate in certificates.data"
|
||||||
:key="certificate.name"
|
:key="certificate.name"
|
||||||
@@ -34,13 +34,9 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const certificates = createResource({
|
const certificates = createResource({
|
||||||
url: 'frappe.client.get_list',
|
url: 'lms.lms.api.get_certificates',
|
||||||
params: {
|
params: {
|
||||||
doctype: 'LMS Certificate',
|
member: props.profile.data.name,
|
||||||
fields: ['name', 'course', 'course_title', 'issue_date', 'template'],
|
|
||||||
filters: {
|
|
||||||
member: props.profile.data.name,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="">
|
<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>
|
<div>
|
||||||
{{ __('Day') }}
|
{{ __('Day') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -20,7 +22,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="evaluator.data"
|
v-if="evaluator.data"
|
||||||
v-for="slot in evaluator.data.slots.schedule"
|
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
|
<FormControl
|
||||||
type="select"
|
type="select"
|
||||||
@@ -44,7 +46,10 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<FormControl
|
||||||
type="select"
|
type="select"
|
||||||
:options="days"
|
:options="days"
|
||||||
@@ -74,7 +79,7 @@
|
|||||||
<h2 class="mb-4 text-lg font-semibold text-gray-900">
|
<h2 class="mb-4 text-lg font-semibold text-gray-900">
|
||||||
{{ __('I am unavailable') }}
|
{{ __('I am unavailable') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<FormControl
|
<FormControl
|
||||||
type="date"
|
type="date"
|
||||||
:label="__('From')"
|
:label="__('From')"
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||||
{{ __('Settings') }}
|
{{ __('Settings') }}
|
||||||
</h2>
|
</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
|
<FormControl
|
||||||
:label="__('Moderator')"
|
:label="__('Moderator')"
|
||||||
v-model="moderator"
|
v-model="moderator"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
</header>
|
</header>
|
||||||
<div v-if="chartDetails.data" class="p-5">
|
<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="flex items-center shadow py-2 px-3 rounded-md">
|
||||||
<div class="p-2 rounded-md bg-gray-100 mr-3">
|
<div class="p-2 rounded-md bg-gray-100 mr-3">
|
||||||
<BookOpen class="w-18 h-18 stroke-1.5 text-gray-700" />
|
<BookOpen class="w-18 h-18 stroke-1.5 text-gray-700" />
|
||||||
|
|||||||
@@ -130,6 +130,11 @@ const routes = [
|
|||||||
name: 'CertifiedParticipants',
|
name: 'CertifiedParticipants',
|
||||||
component: () => import('@/pages/CertifiedParticipants.vue'),
|
component: () => import('@/pages/CertifiedParticipants.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/notifications',
|
||||||
|
name: 'Notifications',
|
||||||
|
component: () => import('@/pages/Notifications.vue'),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
let router = createRouter({
|
let router = createRouter({
|
||||||
@@ -138,13 +143,21 @@ let router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const { userResource } = usersStore()
|
const { userResource, allUsers } = usersStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
await userResource.reload()
|
await userResource.reload()
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
isLoggedIn &&
|
||||||
|
(to.name == 'Lesson' ||
|
||||||
|
to.name == 'Batch' ||
|
||||||
|
to.name == 'Notifications')
|
||||||
|
) {
|
||||||
|
await allUsers.reload()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
isLoggedIn = false
|
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 {
|
return {
|
||||||
userResource,
|
userResource,
|
||||||
|
allUsers,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
|
Bell,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Quiz } from '@/utils/quiz'
|
import { Quiz } from '@/utils/quiz'
|
||||||
import { Upload } from '@/utils/upload'
|
import { Upload } from '@/utils/upload'
|
||||||
@@ -350,6 +351,7 @@ export function getSidebarLinks() {
|
|||||||
label: 'Statistics',
|
label: 'Statistics',
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
to: 'Statistics',
|
to: 'Statistics',
|
||||||
|
activeFor: ['Statistics'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export class Quiz {
|
|||||||
}
|
}
|
||||||
|
|
||||||
save(blockContent) {
|
save(blockContent) {
|
||||||
console.log(blockContent)
|
|
||||||
return {
|
return {
|
||||||
quiz: this.data.quiz,
|
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
|
# Hook on document methods and events
|
||||||
|
|
||||||
doc_events = {
|
doc_events = {
|
||||||
|
"*": {
|
||||||
|
"on_change": [
|
||||||
|
"lms.lms.doctype.lms_badge.lms_badge.process_badges",
|
||||||
|
]
|
||||||
|
},
|
||||||
"Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"},
|
"Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"},
|
||||||
|
"Notification Log": {"on_change": "lms.lms.utils.publish_notifications"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Scheduled Tasks
|
# Scheduled Tasks
|
||||||
@@ -108,7 +114,7 @@ scheduler_events = {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fixtures = ["Custom Field", "Function", "Industry"]
|
fixtures = ["Custom Field", "Function", "Industry", "LMS Badge"]
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
# -------
|
# -------
|
||||||
@@ -146,9 +152,8 @@ website_redirects = [
|
|||||||
{"source": "/update-profile", "target": "/edit-profile"},
|
{"source": "/update-profile", "target": "/edit-profile"},
|
||||||
{"source": "/courses", "target": "/lms/courses"},
|
{"source": "/courses", "target": "/lms/courses"},
|
||||||
{
|
{
|
||||||
"source": r"/courses/([^/]*)",
|
"source": r"^/courses/.*$",
|
||||||
"target": "/lms/courses",
|
"target": "/lms/courses",
|
||||||
"match_with_query_string": True,
|
|
||||||
},
|
},
|
||||||
{"source": "/batches", "target": "/lms/batches"},
|
{"source": "/batches", "target": "/lms/batches"},
|
||||||
{
|
{
|
||||||
@@ -232,7 +237,8 @@ jinja = {
|
|||||||
# ]
|
# ]
|
||||||
|
|
||||||
has_website_permission = {
|
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 = [
|
profile_mandatory_fields = [
|
||||||
@@ -270,6 +276,7 @@ lms_markdown_macro_renderers = {
|
|||||||
page_renderer = [
|
page_renderer = [
|
||||||
"lms.page_renderers.ProfileRedirectPage",
|
"lms.page_renderers.ProfileRedirectPage",
|
||||||
"lms.page_renderers.ProfilePage",
|
"lms.page_renderers.ProfilePage",
|
||||||
|
"lms.page_renderers.CoursePage",
|
||||||
]
|
]
|
||||||
|
|
||||||
# set this to "/" to have profiles on the top-level
|
# set this to "/" to have profiles on the top-level
|
||||||
|
|||||||
@@ -330,12 +330,13 @@ def get_evaluator_details(evaluator):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def get_certified_participants():
|
def get_certified_participants(search_query=""):
|
||||||
LMSCertificate = DocType("LMS Certificate")
|
LMSCertificate = DocType("LMS Certificate")
|
||||||
participants = (
|
participants = (
|
||||||
frappe.qb.from_(LMSCertificate)
|
frappe.qb.from_(LMSCertificate)
|
||||||
.select(LMSCertificate.member)
|
.select(LMSCertificate.member)
|
||||||
.distinct()
|
.distinct()
|
||||||
|
.where(LMSCertificate.member_name.like(f"%{search_query}%"))
|
||||||
.where(LMSCertificate.published == 1)
|
.where(LMSCertificate.published == 1)
|
||||||
.orderby(LMSCertificate.creation, order=frappe.qb.desc)
|
.orderby(LMSCertificate.creation, order=frappe.qb.desc)
|
||||||
.run(as_dict=1)
|
.run(as_dict=1)
|
||||||
@@ -355,7 +356,62 @@ def get_certified_participants():
|
|||||||
courses = []
|
courses = []
|
||||||
for course in course_names:
|
for course in course_names:
|
||||||
courses.append(frappe.db.get_value("LMS Course", course, "title"))
|
courses.append(frappe.db.get_value("LMS Course", course, "title"))
|
||||||
details.courses = courses
|
details["courses"] = courses
|
||||||
participant_details.append(details)
|
participant_details.append(details)
|
||||||
|
|
||||||
return participant_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()
|
self.validate_unavailability()
|
||||||
|
|
||||||
def validate_unavailability(self):
|
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 (
|
if (
|
||||||
self.unavailable_from
|
self.unavailable_from
|
||||||
and self.unavailable_to
|
and self.unavailable_to
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from frappe.model.document import Document
|
|||||||
from frappe.utils.telemetry import capture
|
from frappe.utils.telemetry import capture
|
||||||
from lms.lms.utils import get_course_progress
|
from lms.lms.utils import get_course_progress
|
||||||
from ...md import find_macros
|
from ...md import find_macros
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
class CourseLesson(Document):
|
class CourseLesson(Document):
|
||||||
@@ -89,7 +90,7 @@ class CourseLesson(Document):
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def save_progress(lesson, course):
|
def save_progress(lesson, course):
|
||||||
membership = frappe.db.exists(
|
membership = frappe.db.exists(
|
||||||
"LMS Enrollment", {"member": frappe.session.user, "course": course}
|
"LMS Enrollment", {"course": course, "member": frappe.session.user}
|
||||||
)
|
)
|
||||||
if not membership:
|
if not membership:
|
||||||
return 0
|
return 0
|
||||||
@@ -114,23 +115,52 @@ def save_progress(lesson, course):
|
|||||||
|
|
||||||
progress = get_course_progress(course)
|
progress = get_course_progress(course)
|
||||||
frappe.db.set_value("LMS Enrollment", membership, "progress", progress)
|
frappe.db.set_value("LMS Enrollment", membership, "progress", progress)
|
||||||
|
enrollment = frappe.get_doc("LMS Enrollment", membership)
|
||||||
|
enrollment.run_method("on_change")
|
||||||
return progress
|
return progress
|
||||||
|
|
||||||
|
|
||||||
def get_quiz_progress(lesson):
|
def get_quiz_progress(lesson):
|
||||||
body = frappe.db.get_value("Course Lesson", lesson, "body")
|
lesson_details = frappe.db.get_value(
|
||||||
macros = find_macros(body)
|
"Course Lesson", lesson, ["body", "content"], as_dict=1
|
||||||
quizzes = [value for name, value in macros if name == "Quiz"]
|
)
|
||||||
|
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:
|
for quiz in quizzes:
|
||||||
|
print(quiz)
|
||||||
passing_percentage = frappe.db.get_value("LMS Quiz", quiz, "passing_percentage")
|
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(
|
if not frappe.db.exists(
|
||||||
"LMS Quiz Submission",
|
"LMS Quiz Submission",
|
||||||
{
|
{
|
||||||
"quiz": quiz,
|
"quiz": quiz,
|
||||||
"owner": frappe.session.user,
|
"member": frappe.session.user,
|
||||||
"percentage": [">=", passing_percentage],
|
"percentage": [">=", passing_percentage],
|
||||||
},
|
},
|
||||||
):
|
):
|
||||||
|
print("no submission")
|
||||||
return False
|
return False
|
||||||
return True
|
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": [],
|
"actions": [],
|
||||||
|
"allow_import": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"autoname": "format: CLS-{#####}",
|
"autoname": "format: CLS-{#####}",
|
||||||
"creation": "2022-11-09 16:14:05.876933",
|
"creation": "2022-11-09 16:14:05.876933",
|
||||||
@@ -304,7 +305,7 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-04-17 10:35:21.957961",
|
"modified": "2024-05-14 14:47:48.839162",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Batch",
|
"name": "LMS Batch",
|
||||||
@@ -352,5 +353,6 @@
|
|||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
"title_field": "title"
|
"title_field": "title",
|
||||||
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_import": 1,
|
||||||
"creation": "2021-08-16 15:47:19.494055",
|
"creation": "2021-08-16 15:47:19.494055",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
"editable_grid": 1,
|
"editable_grid": 1,
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-04-09 13:42:18.350028",
|
"modified": "2024-05-14 14:48:31.650107",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Certificate",
|
"name": "LMS Certificate",
|
||||||
@@ -116,6 +117,15 @@
|
|||||||
"role": "Moderator",
|
"role": "Moderator",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "LMS Student",
|
||||||
|
"share": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"sort_field": "modified",
|
"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()
|
@frappe.whitelist()
|
||||||
def create_certificate(course):
|
def create_certificate(course):
|
||||||
certificate = is_certified(course)
|
certificate = is_certified(course)
|
||||||
@@ -91,7 +97,13 @@ def create_certificate(course):
|
|||||||
},
|
},
|
||||||
"value",
|
"value",
|
||||||
)
|
)
|
||||||
|
if not default_certificate_template:
|
||||||
|
default_certificate_template = frappe.db.get_value(
|
||||||
|
"Print Format",
|
||||||
|
{
|
||||||
|
"doc_type": "LMS Certificate",
|
||||||
|
},
|
||||||
|
)
|
||||||
certificate = frappe.get_doc(
|
certificate = frappe.get_doc(
|
||||||
{
|
{
|
||||||
"doctype": "LMS Certificate",
|
"doctype": "LMS Certificate",
|
||||||
|
|||||||
@@ -76,8 +76,7 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "video_link",
|
"fieldname": "video_link",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Video Embed Link",
|
"label": "Video Embed Link"
|
||||||
"reqd": 1
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "short_introduction",
|
"fieldname": "short_introduction",
|
||||||
@@ -274,7 +273,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2024-04-16 17:40:50.899368",
|
"modified": "2024-05-08 15:11:07.833094",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Course",
|
"name": "LMS Course",
|
||||||
@@ -305,7 +304,6 @@
|
|||||||
"write": 1
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"search_fields": "title",
|
|
||||||
"show_title_field_in_link": 1,
|
"show_title_field_in_link": 1,
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import json
|
|||||||
import random
|
import random
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.utils import cint
|
from frappe.utils import cint, today
|
||||||
from frappe.utils.telemetry import capture
|
from frappe.utils.telemetry import capture
|
||||||
from lms.lms.utils import get_chapters, can_create_courses
|
from lms.lms.utils import get_chapters, can_create_courses
|
||||||
from ...utils import generate_slug, validate_image
|
from ...utils import generate_slug, validate_image
|
||||||
@@ -14,11 +14,16 @@ from frappe import _
|
|||||||
|
|
||||||
class LMSCourse(Document):
|
class LMSCourse(Document):
|
||||||
def validate(self):
|
def validate(self):
|
||||||
|
self.validate_published()
|
||||||
self.validate_instructors()
|
self.validate_instructors()
|
||||||
self.validate_video_link()
|
self.validate_video_link()
|
||||||
self.validate_status()
|
self.validate_status()
|
||||||
self.image = validate_image(self.image)
|
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):
|
def validate_instructors(self):
|
||||||
if self.is_new() and not self.instructors:
|
if self.is_new() and not self.instructors:
|
||||||
frappe.get_doc(
|
frappe.get_doc(
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ def new_course(title, additional_filters=None):
|
|||||||
"title": title,
|
"title": title,
|
||||||
"short_introduction": title,
|
"short_introduction": title,
|
||||||
"description": title,
|
"description": title,
|
||||||
|
"video_link": "https://youtu.be/pEbIhUySqbk",
|
||||||
|
"image": "/assets/lms/images/course-home.png",
|
||||||
}
|
}
|
||||||
|
|
||||||
if additional_filters:
|
if additional_filters:
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
# Copyright (c) 2021, FOSS United and contributors
|
# Copyright (c) 2021, FOSS United and contributors
|
||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
# import frappe
|
import frappe
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
from lms.lms.utils import get_course_progress
|
||||||
|
|
||||||
|
|
||||||
class LMSCourseProgress(Document):
|
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": [],
|
"actions": [],
|
||||||
|
"allow_import": 1,
|
||||||
"creation": "2022-02-07 12:01:40.929633",
|
"creation": "2022-02-07 12:01:40.929633",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
"editable_grid": 1,
|
"editable_grid": 1,
|
||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"course",
|
"course",
|
||||||
"member_type",
|
"progress",
|
||||||
"payment",
|
"payment",
|
||||||
|
"current_lesson",
|
||||||
"column_break_3",
|
"column_break_3",
|
||||||
"member",
|
"member",
|
||||||
"member_name",
|
"member_name",
|
||||||
@@ -17,8 +19,7 @@
|
|||||||
"subgroup",
|
"subgroup",
|
||||||
"batch_old",
|
"batch_old",
|
||||||
"column_break_12",
|
"column_break_12",
|
||||||
"current_lesson",
|
"member_type",
|
||||||
"progress",
|
|
||||||
"role"
|
"role"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -113,7 +114,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "section_break_8",
|
"fieldname": "section_break_8",
|
||||||
"fieldtype": "Section Break"
|
"fieldtype": "Section Break",
|
||||||
|
"hidden": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "payment",
|
"fieldname": "payment",
|
||||||
@@ -124,7 +126,7 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-03-18 17:32:30.182301",
|
"modified": "2024-05-14 14:50:08.405033",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Enrollment",
|
"name": "LMS Enrollment",
|
||||||
@@ -173,5 +175,6 @@
|
|||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"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,
|
"passing_percentage": quiz_details.passing_percentage,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
submission.save(ignore_permissions=True)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
percentage >= quiz_details.passing_percentage
|
percentage >= quiz_details.passing_percentage
|
||||||
@@ -110,8 +111,8 @@ def quiz_summary(quiz, results):
|
|||||||
and quiz_details.course
|
and quiz_details.course
|
||||||
):
|
):
|
||||||
save_progress(quiz_details.lesson, quiz_details.course)
|
save_progress(quiz_details.lesson, quiz_details.course)
|
||||||
|
elif not quiz_details.passing_percentage:
|
||||||
submission.save(ignore_permissions=True)
|
save_progress(quiz_details.lesson, quiz_details.course)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"score": score,
|
"score": score,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "answer",
|
"fieldname": "answer",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Small Text",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Users Response",
|
"label": "Users Response",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2023-10-17 11:55:25.641214",
|
"modified": "2024-05-17 17:38:51.760653",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Quiz Result",
|
"name": "LMS Quiz Result",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% set title = frappe.db.get_value("LMS Course", doc.course, "title") %}
|
{% 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>
|
<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
|
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.
|
"""If chapter is passed, returns lessons of only that chapter.
|
||||||
Else returns lessons of all chapters of the course"""
|
Else returns lessons of all chapters of the course"""
|
||||||
lessons = []
|
lessons = []
|
||||||
lesson_count = 0
|
lesson_count = 0
|
||||||
if chapter:
|
if chapter:
|
||||||
if get_details:
|
if get_details:
|
||||||
return get_lesson_details(chapter)
|
return get_lesson_details(chapter, progress=progress)
|
||||||
else:
|
else:
|
||||||
return frappe.db.count("Lesson Reference", {"parent": chapter.name})
|
return frappe.db.count("Lesson Reference", {"parent": chapter.name})
|
||||||
|
|
||||||
for chapter in get_chapters(course):
|
for chapter in get_chapters(course):
|
||||||
if get_details:
|
if get_details:
|
||||||
lessons += get_lesson_details(chapter)
|
lessons += get_lesson_details(chapter, progress=progress)
|
||||||
else:
|
else:
|
||||||
lesson_count += frappe.db.count("Lesson Reference", {"parent": chapter.name})
|
lesson_count += frappe.db.count("Lesson Reference", {"parent": chapter.name})
|
||||||
|
|
||||||
return lessons if get_details else lesson_count
|
return lessons if get_details else lesson_count
|
||||||
|
|
||||||
|
|
||||||
def get_lesson_details(chapter):
|
def get_lesson_details(chapter, progress=False):
|
||||||
lessons = []
|
lessons = []
|
||||||
lesson_list = frappe.get_all(
|
lesson_list = frappe.get_all(
|
||||||
"Lesson Reference", {"parent": chapter.name}, ["lesson", "idx"], order_by="idx"
|
"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.number = f"{chapter.idx}.{row.idx}"
|
||||||
lesson_details.icon = get_lesson_icon(lesson_details.body)
|
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)
|
lessons.append(lesson_details)
|
||||||
return lessons
|
return lessons
|
||||||
|
|
||||||
@@ -277,21 +281,21 @@ def get_lesson_index(lesson_name):
|
|||||||
"Lesson Reference", {"lesson": lesson_name}, ["idx", "parent"], as_dict=True
|
"Lesson Reference", {"lesson": lesson_name}, ["idx", "parent"], as_dict=True
|
||||||
)
|
)
|
||||||
if not lesson:
|
if not lesson:
|
||||||
return "1.1"
|
return "1-1"
|
||||||
|
|
||||||
chapter = frappe.db.get_value(
|
chapter = frappe.db.get_value(
|
||||||
"Chapter Reference", {"chapter": lesson.parent}, ["idx"], as_dict=True
|
"Chapter Reference", {"chapter": lesson.parent}, ["idx"], as_dict=True
|
||||||
)
|
)
|
||||||
if not chapter:
|
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):
|
def get_lesson_url(course, lesson_number):
|
||||||
if not lesson_number:
|
if not lesson_number:
|
||||||
return
|
return
|
||||||
return f"/lms/courses/{course}/learn/{lesson_number}"
|
return f"/courses/{course}/learn/{lesson_number}"
|
||||||
|
|
||||||
|
|
||||||
def get_batch(course, batch_name):
|
def get_batch(course, batch_name):
|
||||||
@@ -306,7 +310,7 @@ def get_progress(course, lesson, member=None):
|
|||||||
if not member:
|
if not member:
|
||||||
member = frappe.session.user
|
member = frappe.session.user
|
||||||
|
|
||||||
return frappe.db.get_value(
|
return frappe.db.exists(
|
||||||
"LMS Course Progress",
|
"LMS Course Progress",
|
||||||
{"course": course, "member": member, "lesson": lesson},
|
{"course": course, "member": member, "lesson": lesson},
|
||||||
["status"],
|
["status"],
|
||||||
@@ -379,7 +383,7 @@ def get_course_progress(course, member=None):
|
|||||||
return 0
|
return 0
|
||||||
completed_lessons = frappe.db.count(
|
completed_lessons = frappe.db.count(
|
||||||
"LMS Course Progress",
|
"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
|
precision = cint(frappe.db.get_default("float_precision")) or 3
|
||||||
return flt(((completed_lessons / lesson_count) * 100), precision)
|
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"]:
|
if topic.reference_doctype not in ["Course Lesson", "LMS Batch"]:
|
||||||
return
|
return
|
||||||
create_notification_log(doc, topic)
|
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):
|
def create_notification_log(doc, topic):
|
||||||
course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course")
|
users = []
|
||||||
instructors = frappe.db.get_all(
|
if topic.reference_doctype == "LMS Course":
|
||||||
"Course Instructor", {"parent": course}, pluck="instructor"
|
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(
|
notification = frappe._dict(
|
||||||
{
|
{
|
||||||
"subject": _("New reply on the topic {0}").format(topic.title),
|
"subject": subject,
|
||||||
"email_content": doc.reply,
|
"email_content": doc.reply,
|
||||||
"document_type": topic.reference_doctype,
|
"document_type": topic.reference_doctype,
|
||||||
"document_name": topic.reference_docname,
|
"document_name": topic.reference_docname,
|
||||||
"for_user": topic.owner,
|
"for_user": topic.owner,
|
||||||
"from_user": doc.owner,
|
"from_user": doc.owner,
|
||||||
"type": "Alert",
|
"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)
|
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)
|
mentions = extract_mentions(doc.reply)
|
||||||
if not mentions:
|
if not mentions:
|
||||||
return
|
return
|
||||||
@@ -1208,6 +1266,7 @@ def get_course_details(course):
|
|||||||
"short_introduction",
|
"short_introduction",
|
||||||
"published",
|
"published",
|
||||||
"upcoming",
|
"upcoming",
|
||||||
|
"disable_self_learning",
|
||||||
"published_on",
|
"published_on",
|
||||||
"status",
|
"status",
|
||||||
"paid_course",
|
"paid_course",
|
||||||
@@ -1299,7 +1358,7 @@ def get_categorized_courses(courses):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def get_course_outline(course):
|
def get_course_outline(course, progress=False):
|
||||||
"""Returns the course outline."""
|
"""Returns the course outline."""
|
||||||
outline = []
|
outline = []
|
||||||
chapters = frappe.get_all(
|
chapters = frappe.get_all(
|
||||||
@@ -1313,7 +1372,7 @@ def get_course_outline(course):
|
|||||||
as_dict=True,
|
as_dict=True,
|
||||||
)
|
)
|
||||||
chapter_details["idx"] = chapter.idx
|
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)
|
outline.append(chapter_details)
|
||||||
return outline
|
return outline
|
||||||
|
|
||||||
@@ -1519,10 +1578,12 @@ def get_question_details(question):
|
|||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def get_batch_courses(batch):
|
def get_batch_courses(batch):
|
||||||
courses = []
|
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:
|
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
|
return courses
|
||||||
|
|
||||||
@@ -1701,6 +1762,7 @@ def create_discussion_topic(doctype, docname):
|
|||||||
doc = frappe.new_doc("Discussion Topic")
|
doc = frappe.new_doc("Discussion Topic")
|
||||||
doc.update(
|
doc.update(
|
||||||
{
|
{
|
||||||
|
"title": docname,
|
||||||
"reference_doctype": doctype,
|
"reference_doctype": doctype,
|
||||||
"reference_docname": docname,
|
"reference_docname": docname,
|
||||||
}
|
}
|
||||||
@@ -1810,3 +1872,9 @@ def get_roles(name):
|
|||||||
"batch_evaluator": has_course_evaluator_role(name),
|
"batch_evaluator": has_course_evaluator_role(name),
|
||||||
"lms_student": has_student_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"
|
"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",
|
"creation": "2021-10-21 17:20:01.358903",
|
||||||
|
"custom_blocks": [],
|
||||||
"docstatus": 0,
|
"docstatus": 0,
|
||||||
"doctype": "Workspace",
|
"doctype": "Workspace",
|
||||||
"hide_custom": 0,
|
"hide_custom": 0,
|
||||||
@@ -144,7 +145,7 @@
|
|||||||
"type": "Link"
|
"type": "Link"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2023-05-11 15:41:25.514443",
|
"modified": "2024-05-09 14:44:08.590606",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS",
|
"name": "LMS",
|
||||||
|
|||||||
@@ -107,3 +107,34 @@ def render_portal_page(path, **kwargs):
|
|||||||
frappe.form_dict.update(kwargs)
|
frappe.form_dict.update(kwargs)
|
||||||
page = TemplatePage(path)
|
page = TemplatePage(path)
|
||||||
return page.render()
|
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.custom_perm_for_discussions #14-01-2024
|
||||||
lms.patches.v1_0.rename_evaluator_role
|
lms.patches.v1_0.rename_evaluator_role
|
||||||
lms.patches.v1_0.change_navbar_urls
|
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():
|
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")
|
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" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/assets/lms/frontend/favicon.png" />
|
<link rel="icon" href="/assets/lms/frontend/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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="title" content="{{ meta.title }}" />
|
||||||
<meta name="image" content="{{ meta.image }}" />
|
<meta name="image" content="{{ meta.image }}" />
|
||||||
<meta name="description" content="{{ meta.description }}" />
|
<meta name="description" content="{{ meta.description }}" />
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
<meta name="twitter:title" content="{{ meta.title }}" />
|
<meta name="twitter:title" content="{{ meta.title }}" />
|
||||||
<meta name="twitter:image" content="{{ meta.image }}" />
|
<meta name="twitter:image" content="{{ meta.image }}" />
|
||||||
<meta name="twitter:description" content="{{ meta.description }}" />
|
<meta name="twitter:description" content="{{ meta.description }}" />
|
||||||
<script type="module" crossorigin src="/assets/lms/frontend/assets/index-B0I4dIsL.js"></script>
|
<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-BlL1CpdE.js">
|
<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/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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<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
|
import requests
|
||||||
|
|
||||||
url = f"{base_url}{path}"
|
url = f"{base_url}{path}"
|
||||||
print(url)
|
|
||||||
res = requests.get(
|
res = requests.get(
|
||||||
url,
|
url,
|
||||||
headers={
|
headers={
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ def get_meta(app_path):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if re.match(r"^courses/.*$", 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_name = app_path.split("/")[1]
|
||||||
course = frappe.db.get_value(
|
course = frappe.db.get_value(
|
||||||
"LMS Course",
|
"LMS Course",
|
||||||
@@ -56,7 +64,6 @@ def get_meta(app_path):
|
|||||||
"link": "/batches",
|
"link": "/batches",
|
||||||
}
|
}
|
||||||
if re.match(r"^batches/details/.*$", app_path):
|
if re.match(r"^batches/details/.*$", app_path):
|
||||||
print(app_path, "app_path")
|
|
||||||
batch_name = app_path.split("/")[2]
|
batch_name = app_path.split("/")[2]
|
||||||
batch = frappe.db.get_value(
|
batch = frappe.db.get_value(
|
||||||
"LMS Batch",
|
"LMS Batch",
|
||||||
@@ -90,7 +97,7 @@ def get_meta(app_path):
|
|||||||
as_dict=True,
|
as_dict=True,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"title": job_opening.title,
|
"title": job_opening.job_title,
|
||||||
"image": job_opening.company_logo,
|
"image": job_opening.company_logo,
|
||||||
"description": job_opening.company_name,
|
"description": job_opening.company_name,
|
||||||
"keywords": "Job Openings, Jobs, Vacancies",
|
"keywords": "Job Openings, Jobs, Vacancies",
|
||||||
|
|||||||
@@ -22,5 +22,9 @@
|
|||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/frappe/lms/issues"
|
"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