Merge branch 'develop' into patch-2

This commit is contained in:
Jannat Patel
2024-05-24 15:25:53 +05:30
committed by GitHub
88 changed files with 8141 additions and 4432 deletions

View File

@@ -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

View File

@@ -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."
);
}); });
}); });

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -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

View File

@@ -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",

View File

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

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

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

View File

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

View File

@@ -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(
{}, {},

View File

@@ -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],

View File

@@ -48,7 +48,7 @@
{{ {{
uploading uploading
? __('Uploading {0}%').format(progress) ? __('Uploading {0}%').format(progress)
: __('Upload an File') : __('Upload a File')
}} }}
</Button> </Button>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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('')

View File

@@ -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',

View File

@@ -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

View File

@@ -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>

View File

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

View File

@@ -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">&middot;</span> <span class="hidden lg:block" v-if="batch.data.courses"
>&middot;</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">&middot;</span> <span class="hidden lg:block" v-if="batch.data.start_date"
>&middot;</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'

View File

@@ -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' },

View File

@@ -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(() => {

View File

@@ -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;

View File

@@ -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

View File

@@ -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 = [
{ {

View File

@@ -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>

View File

@@ -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 {

View 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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

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

View File

@@ -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" />

View File

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

View File

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

View File

@@ -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'],
}, },
] ]
} }

View File

@@ -43,7 +43,6 @@ export class Quiz {
} }
save(blockContent) { save(blockContent) {
console.log(blockContent)
return { return {
quiz: this.data.quiz, quiz: this.data.quiz,
} }

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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"
}
]

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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

View File

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

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

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

View 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

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

View 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"
}

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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(

View File

@@ -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:

View File

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

View File

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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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>

View File

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

View File

@@ -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",

View File

@@ -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()

View File

@@ -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

View File

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

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

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

View File

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

View File

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

View File

@@ -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={

View File

@@ -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",

View File

@@ -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"
}
}

4501
yarn.lock Normal file

File diff suppressed because it is too large Load Diff