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
- 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", () => {
it("creates a new course", () => {
cy.login();
cy.visit("/courses");
cy.wait(1000);
cy.visit("/lms/courses");
// Create a course
cy.get("a.btn").contains("Create a Course").click();
cy.wait(1000);
cy.url().should("include", "/courses/new-course/edit");
cy.get("#title").type("Test Course");
cy.get("#intro").type("Test Course Short Introduction");
cy.get("#description").type("Test Course Description");
cy.get("#video-link").type("-LPmw2Znl2c");
cy.get("#tags-input").type("Test");
cy.get("#published").check();
cy.get("a").contains("New Course").click();
cy.wait(1000);
cy.url().should("include", "/courses/new/edit");
cy.get("label").contains("Title").type("Test Course");
cy.get("label")
.contains("Short Introduction")
.type("Test Course Short Introduction to test the UI");
cy.get("div[contenteditable=true").invoke(
"text",
"Test Course Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
cy.fixture("profile.png", "base64").then((fileContent) => {
cy.get('input[type="file"]').attachFile({
fileContent,
fileName: "profile.png",
mimeType: "image/png",
encoding: "base64",
});
});
cy.get("label")
.contains("Preview Video")
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
cy.get("label").contains("Published").click();
cy.get("label").contains("Published On").type("2021-01-01");
cy.button("Save").click();
// Add Chapter
cy.wait(1000);
cy.link("Course Outline").click();
cy.button("Add Chapter").click();
cy.wait(1000);
cy.get(".edit-header .btn-add-chapter").click();
cy.wait(500);
cy.get("#chapter-title").type("Test Chapter");
cy.get("#chapter-description").type("Test Chapter Description");
cy.button("Save").click();
cy.get("[id^=headlessui-dialog-panel-")
.should("be.visible")
.within(() => {
cy.get("label").contains("Title").type("Test Chapter");
cy.button("Add Chapter").click();
});
// Add Lesson
cy.wait(1000);
cy.link("Add Lesson").click();
cy.button("Add Lesson").click();
cy.wait(1000);
cy.url().should("include", "/learn/1-1/edit");
cy.wait(1000);
cy.get("#lesson-title").type("Test Lesson");
// Content
cy.get(".collapse-section.collapsed:first").click();
cy.get("#lesson-content .ce-block")
cy.get("label").contains("Title").type("Test Lesson");
/* cy.get("#content .ce-block")
.click()
.type(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now. {enter}"
);
cy.get("#lesson-content .ce-toolbar__plus").click();
cy.get('#lesson-content [data-item-name="youtube"]').click();
cy.get('input[data-fieldname="youtube"]').type("GoDtyItReto");
cy.button("Insert").click();
cy.wait(1000);
.invoke("text", "https://www.youtube.com/watch?v=GoDtyItReto"); */
/* cy.get("#content .ce-block")
.click()
.paste("https://www.youtube.com/watch?v=GoDtyItReto"); */
cy.fixture("Youtube.mov", "base64").then((fileContent) => {
cy.get('input[type="file"]').attachFile({
fileContent,
fileName: "Youtube.mov",
mimeType: "image/png",
encoding: "base64",
});
});
cy.get("#content .ce-block").type(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
cy.button("Save").click();
// View Course
cy.wait(1000);
cy.visit("/courses");
cy.get(".course-card-title:first").contains("Test Course");
cy.get(".course-card:first").click();
cy.url().should("include", "/courses/test-course");
cy.get("#title").contains("Test Course");
cy.get(".preview-video").should(
cy.visit("/lms");
cy.wait(500);
cy.url().should("include", "/lms/courses");
cy.get(".grid a:first").within(() => {
cy.get("div").contains("Test Course");
cy.get("div").contains(
"Test Course Short Introduction to test the UI"
);
cy.get(".course-image")
.invoke("css", "background-image")
.should("include", "/files/profile");
});
cy.get(".grid a:first").click();
cy.url().should("include", "/lms/courses/test-course");
cy.get("div").contains("Test Course");
cy.get("div").contains("Test Course Short Introduction to test the UI");
cy.get("div").contains("Learning");
cy.get("div").contains("Frappe");
cy.get("div").contains("ERPNext");
cy.get("iframe").should(
"have.attr",
"src",
"https://www.youtube.com/embed/-LPmw2Znl2c"
);
cy.get("#intro").contains("Test Course Short Introduction");
// View Chapter
cy.get(".chapter-title-main:first").contains("Test Chapter");
cy.get(".chapter-description:first").contains(
"Test Chapter Description"
);
cy.get(".lesson-info:first").contains("Test Lesson");
cy.get(".lesson-info:first").click();
cy.get("div").contains("Test Chapter");
cy.get("[id^=headlessui-disclosure-panel-").within(() => {
cy.get("div").contains("Test Lesson").click();
});
cy.wait(1000);
// View Lesson
cy.wait(1000);
cy.url().should("include", "learn/1.1");
cy.get("#title").contains("Test Lesson");
cy.get(".lesson-video iframe").should(
"have.attr",
"src",
"https://www.youtube.com/embed/GoDtyItReto"
);
cy.get(".lesson-content-card").contains(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
cy.url().should("include", "/learn/1-1");
cy.get("div").contains("Test Lesson");
cy.get("div").contains(
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now. "
);
cy.get("video")
.should("be.visible")
.children("source")
.invoke("attr", "src")
.should("include", "/files/Youtube");
// Add Discussion
cy.get(".reply").click();
cy.button("New Question").click();
cy.wait(500);
cy.get(".discussion-modal").should("be.visible");
// Enter title
cy.get(".modal .topic-title")
.type("Discussion from tests")
.should("have.value", "Discussion from tests");
// Enter comment
cy.get(".modal .discussions-comment").type(
"This is a discussion from the cypress ui tests."
);
// Submit
cy.get(".modal .submit-discussion").click();
cy.wait(2000);
// Check if discussion is added to page and content is visible
cy.get(".sidebar-parent:first .discussion-topic-title").should(
"have.text",
"Discussion from tests"
);
cy.get(".sidebar-parent:first .discussion-topic-title").click();
cy.get(".discussion-on-page:visible").should("have.class", "show");
cy.get(
".discussion-on-page:visible .reply-card .reply-text .ql-editor p"
).should(
"have.text",
"This is a discussion from the cypress ui tests."
);
cy.get(".discussion-form:visible .discussions-comment").type(
"This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page."
);
cy.get(".discussion-form:visible .submit-discussion").click();
cy.wait(3000);
cy.get(".discussion-on-page:visible").should("have.class", "show");
cy.get(".discussion-on-page:visible")
.children(".reply-card")
.eq(1)
.find(".reply-text")
.should(
"have.text",
"This is a discussion from the cypress ui tests. This comment was entered through the commentbox on the page.\n"
cy.get("[id^=headlessui-dialog-panel-").within(() => {
cy.get("label").contains("Title").type("Test Discussion");
cy.get("div[contenteditable=true]").invoke(
"text",
"This is a test discussion. This will check if the UI is working properly."
);
cy.button("Post").click();
});
// View Discussion
cy.wait(500);
cy.get("div").contains("Test Discussion").click();
cy.get("div[contenteditable=true").invoke(
"text",
"This is a test comment. This will check if the UI is working properly."
);
cy.get("div").contains(
"This is a test comment. This will check if the UI is working properly."
);
});
});

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 --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
import "cypress-file-upload";
Cypress.Commands.add("login", (email, password) => {
if (!email) {
email = Cypress.config("testUser") || "Administrator";
@@ -53,3 +55,13 @@ Cypress.Commands.add("iconButton", (text) => {
Cypress.Commands.add("dialog", (selector) => {
return cy.get(`[role=dialog] ${selector}`);
});
Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
cy.wrap(subject).then(($element) => {
const element = $element[0];
element.focus();
element.textContent = text;
const event = new Event("paste", { bubbles: true });
element.dispatchEvent(event);
});
});

Submodule frappe-ui updated: c5faaae38e...38728b80aa

View File

@@ -21,7 +21,7 @@
"chart.js": "^4.4.1",
"dayjs": "^1.11.6",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.50",
"frappe-ui": "^0.1.56",
"lucide-vue-next": "^0.309.0",
"markdown-it": "^14.0.0",
"pinia": "^2.0.33",

View File

@@ -10,7 +10,7 @@
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
<div class="flex flex-col overflow-y-auto">
<SidebarLink
v-for="link in links"
v-for="link in sidebarLinks"
:link="link"
:isCollapsed="isSidebarCollapsed"
class="mx-2 my-0.5"
@@ -42,10 +42,53 @@ import UserDropdown from '@/components/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import { useStorage } from '@vueuse/core'
import { ref } from 'vue'
import { ref, onMounted, inject, computed } from 'vue'
import { getSidebarLinks } from '../utils'
import { sessionStore } from '@/stores/session'
import { Bell } from 'lucide-vue-next'
import { createResource } from 'frappe-ui'
const links = getSidebarLinks()
const { user } = sessionStore()
const socket = inject('$socket')
const unreadCount = ref(0)
onMounted(() => {
socket.on('publish_lms_notifications', (data) => {
unreadNotifications.reload()
})
})
const unreadNotifications = createResource({
cache: 'Unread Notifications Count',
url: 'frappe.client.get_count',
makeParams(values) {
return {
doctype: 'Notification Log',
filters: {
for_user: user,
read: 0,
},
}
},
onSuccess(data) {
unreadCount.value = data
},
auto: true,
})
const sidebarLinks = computed(() => {
const links = getSidebarLinks()
if (user) {
links.push({
label: 'Notifications',
icon: Bell,
to: 'Notifications',
activeFor: ['Notifications'],
count: unreadCount.value,
})
}
return links
})
const getSidebarFromStorage = () => {
return useStorage('sidebar_is_collapsed', false)

View File

@@ -19,8 +19,14 @@
<ListView
:columns="getCoursesColumns()"
:rows="courses.data"
row-key="name"
:options="{ showTooltip: false }"
row-key="batch_course"
:options="{
showTooltip: false,
getRowRoute: (row) => ({
name: 'CourseDetail',
params: { courseName: row.name },
}),
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
@@ -49,7 +55,10 @@
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button variant="ghost" @click="removeCourses(selections)">
<Button
variant="ghost"
@click="removeCourses(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
@@ -133,11 +142,13 @@ const removeCourse = createResource({
},
})
const removeCourses = (selections) => {
const removeCourses = (selections, unselectAll) => {
selections.forEach(async (course) => {
removeCourse.submit({ course })
await setTimeout(1000)
})
courses.reload()
setTimeout(() => {
courses.reload()
unselectAll()
}, 1000)
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="batch.data" class="shadow rounded-md p-5" style="width: 300px">
<div v-if="batch.data" class="shadow rounded-md p-5 lg:w-72">
<Badge
v-if="batch.data.seat_count && seats_left > 0"
theme="green"

View File

@@ -52,7 +52,10 @@
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button variant="ghost" @click="removeStudents(selections)">
<Button
variant="ghost"
@click="removeStudents(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
@@ -142,11 +145,13 @@ const removeStudent = createResource({
},
})
const removeStudents = (selections) => {
const removeStudents = (selections, unselectAll) => {
selections.forEach(async (student) => {
removeStudent.submit({ student })
await setTimeout(1000)
})
students.reload()
setTimeout(() => {
students.reload()
unselectAll()
}, 500)
}
</script>

View File

@@ -7,7 +7,7 @@
<div
class="course-image"
:class="{ 'default-image': !course.image }"
:style="{ backgroundImage: 'url(' + encodeURI(course.image) + ')' }"
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
>
<div class="flex relative top-4 left-4 w-fit flex-wrap">
<Badge
@@ -147,8 +147,8 @@ const props = defineProps({
display: flex;
flex-direction: column;
align-items: center;
background-color: theme('colors.orange.100');
color: theme('colors.orange.600');
background-color: theme('colors.green.100');
color: theme('colors.green.600');
}
.avatar-group {

View File

@@ -46,6 +46,12 @@
</span>
</Button>
</router-link>
<div
v-else-if="course.data.disable_self_learning"
class="bg-blue-100 text-blue-900 text-sm rounded-md py-1 px-3"
>
{{ __('Contact the Administrator to enroll for this course.') }}
</div>
<Button
v-else
@click="enrollStudent()"
@@ -135,7 +141,6 @@ function enrollStudent() {
const enrollStudentResource = createResource({
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
})
console.log(props.course)
enrollStudentResource
.submit({
course: props.course.data.name,
@@ -160,5 +165,13 @@ function enrollStudent() {
}
}
const is_instructor = () => {}
const is_instructor = () => {
let user_is_instructor = false
props.course.data.instructors.forEach((instructor) => {
if (!user_is_instructor && instructor.name == user.data?.name) {
user_is_instructor = true
}
})
return user_is_instructor
}
</script>

View File

@@ -2,7 +2,7 @@
<div class="text-base">
<div
v-if="title && (outline.data?.length || allowEdit)"
class="flex items-center justify-between mb-4 pl-2"
class="grid grid-cols-[70%,30%] mb-4"
>
<div class="font-semibold text-lg">
{{ __(title) }}
@@ -67,7 +67,7 @@
{{ lesson.title }}
<Check
v-if="lesson.is_complete"
class="h-4 w-4 text-green-500 stroke-1.5 ml-2"
class="h-4 w-4 text-green-700 ml-2"
/>
</div>
</router-link>
@@ -105,7 +105,7 @@
</template>
<script setup>
import { Button, createResource } from 'frappe-ui'
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import {
ChevronRight,
@@ -139,6 +139,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
getProgress: {
type: Boolean,
default: false,
},
})
const outline = createResource({
@@ -146,6 +150,7 @@ const outline = createResource({
cache: ['course_outline', props.courseName],
params: {
course: props.courseName,
progress: props.getProgress,
},
auto: true,
})

View File

@@ -5,7 +5,6 @@
<div
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
>
<slot name="sidebar" />
<AppSidebar />
</div>
<div class="w-full overflow-auto" id="scrollContainer">

View File

@@ -69,9 +69,11 @@
/>
</div>
</div>
<TextEditor
class="mt-5"
:content="newReply"
:mentions="mentionUsers"
@change="(val) => (newReply = val)"
placeholder="Type your reply here..."
:fixedMenu="true"
@@ -92,13 +94,14 @@ import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
import { timeAgo } from '../utils'
import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted } from 'vue'
import { ref, inject, onMounted, computed } from 'vue'
import { createToast } from '../utils'
const showTopics = defineModel('showTopics')
const newReply = ref('')
const socket = inject('$socket')
const user = inject('$user')
const allUsers = inject('$allUsers')
const props = defineProps({
topic: {
@@ -147,6 +150,16 @@ const newReplyResource = createResource({
},
})
const mentionUsers = computed(() => {
let users = Object.values(allUsers.data).map((user) => {
return {
value: user.name,
label: user.full_name,
}
})
return users
})
const postReply = () => {
newReplyResource.submit(
{},

View File

@@ -42,14 +42,14 @@
</div>
<div
v-else
class="flex items-center justify-center border mt-5 p-5 rounded-md"
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
>
<MessageSquareIcon class="w-5 h-5 stroke-1.5 mr-2" />
<div>
<MessageSquareText class="w-7 h-7 text-gray-500 stroke-1.5 mr-2" />
<div class="">
<div v-if="emptyStateTitle" class="font-medium mb-2">
{{ __(emptyStateTitle) }}
</div>
<div class="">
<div class="text-gray-600">
{{ __(emptyStateText) }}
</div>
</div>
@@ -63,13 +63,14 @@
/>
</template>
<script setup>
import { createResource, Button, TextEditor } from 'frappe-ui'
import { createResource, Button } from 'frappe-ui'
import UserAvatar from '@/components/UserAvatar.vue'
import { timeAgo } from '../utils'
import { ref, onMounted, inject } from 'vue'
import DiscussionReplies from '@/components/DiscussionReplies.vue'
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
import { MessageSquareIcon } from 'lucide-vue-next'
import { MessageSquareText } from 'lucide-vue-next'
import { getScrollContainer } from '@/utils/scrollContainer'
const showTopics = ref(true)
const currentTopic = ref(null)
@@ -96,12 +97,16 @@ const props = defineProps({
},
emptyStateText: {
type: String,
default: 'Be the first to start a discussion',
default: 'Start a discussion',
},
singleThread: {
type: Boolean,
default: false,
},
scrollToBottom: {
type: Boolean,
default: false,
},
})
onMounted(() => {
@@ -110,8 +115,19 @@ onMounted(() => {
socket.on('new_discussion_topic', (data) => {
topics.refresh()
})
if (props.scrollToBottom) {
setTimeout(() => {
scrollToEnd()
}, 100)
}
})
const scrollToEnd = () => {
let scrollContainer = getScrollContainer()
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
const topics = createResource({
url: 'lms.lms.utils.get_discussion_topics',
cache: ['topics', props.doctype, props.docname],

View File

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

View File

@@ -29,15 +29,42 @@
<script setup>
import { getSidebarLinks } from '../utils'
import { useRouter } from 'vue-router'
import { computed, inject } from 'vue'
import { computed } from 'vue'
import { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/user'
import { LogOut, LogIn, UserRound } from 'lucide-vue-next'
const { logout, user } = sessionStore()
let { isLoggedIn } = sessionStore()
const router = useRouter()
let { userResource } = usersStore()
const tabs = computed(() => {
return getSidebarLinks()
let links = getSidebarLinks()
if (user) {
links.push({
label: 'Profile',
icon: UserRound,
activeFor: [
'Profile',
'ProfileAbout',
'ProfileCertification',
'ProfileEvaluator',
'ProfileRoles',
],
})
links.push({
label: 'Log out',
icon: LogOut,
})
} else {
links.push({
label: 'Log in',
icon: LogIn,
})
}
return links
})
let isActive = (tab) => {
@@ -50,6 +77,13 @@ const handleClick = (tab) => {
logout.submit().then(() => {
isLoggedIn = false
})
else if (tab.label == 'Profile')
router.push({
name: 'Profile',
params: {
username: userResource.data?.username,
},
})
else router.push({ name: tab.to })
}

View File

@@ -5,7 +5,7 @@
size: '2xl',
actions: [
{
label: 'Submit',
label: 'Post',
variant: 'solid',
onClick: (close) => submitTopic(close),
},
@@ -15,10 +15,7 @@
<template #body-content>
<div class="flex flex-col gap-4">
<div>
<div class="mb-1.5 text-sm text-gray-600">
{{ __('Title') }}
</div>
<Input type="text" v-model="topic.title" />
<FormControl v-model="topic.title" :label="__('Title')" type="text" />
</div>
<div>
<div class="mb-1.5 text-sm text-gray-600">
@@ -37,8 +34,9 @@
</Dialog>
</template>
<script setup>
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
import { reactive, defineModel } from 'vue'
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
import { reactive, defineModel, computed } from 'vue'
import { showToast } from '@/utils'
const topics = defineModel('reloadTopics')
@@ -93,6 +91,14 @@ const submitTopic = (close) => {
topicResource.submit(
{},
{
validate() {
if (!topic.title) {
return 'Title cannot be empty.'
}
if (!topic.reply) {
return 'Reply cannot be empty.'
}
},
onSuccess(data) {
replyResource.submit(
{
@@ -108,6 +114,9 @@ const submitTopic = (close) => {
}
)
},
onError(err) {
showToast('Error', err.message, 'x')
},
}
)
}

View File

@@ -253,8 +253,6 @@ const quiz = createResource({
if (data.shuffle_questions) {
data.questions = data.questions.sort(() => Math.random() - 0.5)
}
attempts.reload()
resetQuiz()
},
})
@@ -286,6 +284,16 @@ const attempts = createResource({
},
})
watch(
() => quiz.data,
() => {
if (quiz.data) {
attempts.reload()
resetQuiz()
}
}
)
const quizSubmission = createResource({
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
makeParams(values) {
@@ -315,7 +323,6 @@ watch(activeQuestion, (value) => {
watch(
() => props.quizName,
(newName) => {
console.log(newName)
if (newName) {
quiz.reload()
}
@@ -384,7 +391,7 @@ const addToLocalStorage = () => {
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
let questionData = {
question_index: activeQuestion.value,
answers: getAnswers().join(),
answer: getAnswers().join(),
is_correct: showAnswers.filter((answer) => {
return answer != undefined
}),

View File

@@ -6,7 +6,7 @@
@click="handleClick"
>
<div
class="flex items-center duration-300 ease-in-out"
class="flex items-center w-full duration-300 ease-in-out"
:class="isCollapsed ? 'p-1' : 'px-2 py-1'"
>
<Tooltip :text="link.label" placement="right">
@@ -29,6 +29,9 @@
>
{{ link.label }}
</span>
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
{{ link.count }}
</span>
</div>
</button>
</template>

View File

@@ -34,9 +34,7 @@ const props = defineProps({
default: 'Tags',
},
})
console.log(props.modelValue)
let tags = ref(props.modelValue)
console.log(tags.value)
const emit = defineEmits(['update:modelValue'])
let newTag = ref('')

View File

@@ -26,7 +26,12 @@
"
>
<div class="text-base font-medium text-gray-900 leading-none">
<span v-if="branding.data?.brand_name">
<span
v-if="
branding.data?.brand_name &&
branding.data?.brand_name != 'Frappe'
"
>
{{ branding.data?.brand_name }}
</span>
<span v-else> Learning </span>
@@ -57,13 +62,23 @@
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { sessionStore } from '@/stores/session'
import { Dropdown, createResource } from 'frappe-ui'
import { ChevronDown, LogIn, LogOut, User } from 'lucide-vue-next'
import {
ChevronDown,
LogIn,
LogOut,
User,
ArrowRightLeft,
} from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { convertToTitleCase } from '../utils'
import { onMounted, inject } from 'vue'
import { usersStore } from '@/stores/user'
const router = useRouter()
const { logout } = sessionStore()
let { userResource } = usersStore()
let { isLoggedIn } = sessionStore()
const props = defineProps({
isCollapsed: {
type: Boolean,
@@ -80,10 +95,6 @@ const branding = createResource({
},
})
const { logout } = sessionStore()
let { userResource } = usersStore()
let { isLoggedIn } = sessionStore()
const userDropdownOptions = [
{
icon: User,
@@ -95,6 +106,19 @@ const userDropdownOptions = [
return isLoggedIn
},
},
{
icon: ArrowRightLeft,
label: 'Switch to Desk',
onClick: () => {
window.location.href = '/app'
},
condition: () => {
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
let system_user = cookies.get('system_user')
if (system_user === 'yes') return true
else return false
},
},
{
icon: LogOut,
label: 'Log out',

View File

@@ -30,8 +30,9 @@ app.provide('$dayjs', dayjs)
app.provide('$socket', initSocket())
app.mount('#app')
const { userResource } = usersStore()
const { userResource, allUsers } = usersStore()
let { isLoggedIn } = sessionStore()
app.provide('$user', userResource)
app.provide('$allUsers', allUsers)
app.config.globalProperties.$user = userResource

View File

@@ -13,7 +13,7 @@
</template>
</Button>
</header>
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-full">
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-screen">
<div class="border-r-2">
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-x-visible">
<template #tab="{ tab, selected }" class="overflow-x-hidden">
@@ -66,9 +66,10 @@
<Discussions
doctype="LMS Batch"
:docname="batch.data.name"
title="Discussions"
:title="__('Discussions')"
:key="batch.data.name"
:singleThread="true"
:scrollToBottom="true"
/>
</div>
</div>

View File

@@ -244,7 +244,7 @@ const newBatch = createResource({
return {
doc: {
doctype: 'LMS Batch',
meta_image: batch.image.file_url,
meta_image: batch.image?.file_url,
...batch,
},
}
@@ -279,7 +279,7 @@ const editBatch = createResource({
doctype: 'LMS Batch',
name: props.batchName,
fieldname: {
meta_image: batch.image.file_url,
meta_image: batch.image?.file_url,
...batch,
},
}

View File

@@ -11,17 +11,23 @@
<div class="my-3">
{{ batch.data.description }}
</div>
<div class="flex items-center justify-between w-1/2">
<div
class="flex flex-col gap-2 lg:gap-0 lg:flex-row lg:items-center justify-between lg:w-1/2"
>
<div class="flex items-center">
<BookOpen class="h-4 w-4 text-gray-700 mr-2" />
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
</div>
<span v-if="batch.data.courses">&middot;</span>
<span class="hidden lg:block" v-if="batch.data.courses"
>&middot;</span
>
<DateRange
:startDate="batch.data.start_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">
<Clock class="h-4 w-4 text-gray-700 mr-2" />
<span>
@@ -31,14 +37,14 @@
</div>
</div>
</div>
<div class="grid grid-cols-[60%,20%] gap-20 mt-10">
<div class="">
<div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
<div class="order-2 lg:order-none">
<div
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-6"
v-html="batch.data.batch_details"
></div>
</div>
<div>
<div class="order-1 lg:order-none">
<BatchOverlay :batch="batch" />
</div>
</div>
@@ -48,7 +54,7 @@
{{ __('Courses') }}
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 mt-5">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-5">
<div
v-if="batch.data.courses"
v-for="course in courses.data"
@@ -79,7 +85,7 @@
<script setup>
import { computed, inject } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
import { BookOpen, Clock } from 'lucide-vue-next'
import { formatTime } from '@/utils'
import { Breadcrumbs, createResource } from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue'

View File

@@ -9,7 +9,7 @@
/>
<div class="flex">
<router-link
v-if="user.data"
v-if="user.data?.is_moderator"
:to="{
name: 'BatchCreation',
params: { batchName: 'new' },

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"
>
<Breadcrumbs :items="breadcrumbs" />
<div>
<FormControl
type="text"
placeholder="Search Participants"
v-model="searchQuery"
@input="participants.reload()"
>
<template #prefix>
<Search class="w-4" name="search" />
</template>
</FormControl>
</div>
</header>
<div class="grid grid-cols-3 gap-4 m-5">
<div v-for="participant in participants.data">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 m-5">
<div v-if="participants.data" v-for="participant in participants.data">
<router-link
:to="{
name: 'Profile',
@@ -38,14 +51,23 @@
</div>
</template>
<script setup>
import { Breadcrumbs, createResource } from 'frappe-ui'
import { computed } from 'vue'
import { Breadcrumbs, FormControl, createResource } from 'frappe-ui'
import { ref, computed } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { Search } from 'lucide-vue-next'
const searchQuery = ref('')
const participants = createResource({
url: 'lms.lms.api.get_certified_participants',
method: 'GET',
cache: ['certified_participants'],
makeParams() {
return {
search_query: searchQuery.value,
}
},
auto: true,
cache: ['certified-participants'],
})
const breadcrumbs = computed(() => {

View File

@@ -149,6 +149,12 @@ updateDocumentTitle(pageMeta)
padding: revert;
}
.course-description ul {
list-style: disc;
margin: revert;
padding: revert;
}
.avatar-group {
display: inline-flex;
align-items: center;

View File

@@ -65,8 +65,8 @@
name: 'Lesson',
params: {
courseName: course.name,
chapterNumber: course.current_lesson.split('.')[0],
lessonNumber: course.current_lesson.split('.')[1],
chapterNumber: course.current_lesson.split('-')[0],
lessonNumber: course.current_lesson.split('-')[1],
},
}
: course.membership

View File

@@ -114,7 +114,11 @@
@click="removeTag(tag)"
/>
</div>
<FormControl v-model="newTag" @keyup.enter="updateTags()" />
<FormControl
v-model="newTag"
@keyup.enter="updateTags()"
id="tags"
/>
</div>
</div>
</div>
@@ -122,7 +126,10 @@
<div class="text-lg font-semibold mt-5 mb-4">
{{ __('Settings') }}
</div>
<div class="flex items-center justify-between mb-5">
<div
v-if="user.data?.is_moderator"
class="flex items-center justify-between mb-4"
>
<FormControl
type="checkbox"
v-model="course.published"
@@ -139,6 +146,12 @@
:label="__('Disable Self Enrollment')"
/>
</div>
<FormControl
v-model="course.published_on"
:label="__('Published On')"
type="date"
class="mb-5"
/>
</div>
<div class="container border-t">
<div class="text-lg font-semibold mt-5 mb-4">
@@ -197,6 +210,7 @@ import CourseOutline from '@/components/CourseOutline.vue'
const user = inject('$user')
const newTag = ref('')
const router = useRouter()
const instructors = ref([])
const props = defineProps({
courseName: {
@@ -212,6 +226,7 @@ const course = reactive({
course_image: null,
tags: '',
published: false,
published_on: '',
upcoming: false,
disable_self_learning: false,
paid_course: false,
@@ -220,9 +235,14 @@ const course = reactive({
})
onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
if (
props.courseName == 'new' &&
!user.data?.is_moderator &&
!user.data?.is_instructor
) {
router.push({ name: 'Courses' })
}
if (props.courseName !== 'new') {
courseResource.reload()
}
@@ -234,7 +254,7 @@ const courseCreationResource = createResource({
return {
doc: {
doctype: 'LMS Course',
image: course.course_image.file_url,
image: course.course_image?.file_url || '',
...values,
},
}
@@ -249,7 +269,7 @@ const courseEditResource = createResource({
doctype: 'LMS Course',
name: values.course,
fieldname: {
image: course.course_image.file_url,
image: course.course_image?.file_url || '',
...course,
},
}
@@ -281,6 +301,8 @@ const courseResource = createResource({
}
if (data.image) imageResource.reload({ image: data.image })
instructors.value = data.instructors
check_permission()
},
})
@@ -358,7 +380,7 @@ watch(
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
return 'Only image file is allowed.'
}
}
@@ -386,6 +408,21 @@ const removeImage = () => {
course.course_image = null
}
const check_permission = () => {
let user_is_instructor = false
if (user.data?.is_moderator) return
instructors.value.forEach((instructor) => {
if (!user_is_instructor && instructor.instructor == user.data?.name) {
user_is_instructor = true
}
})
if (!user_is_instructor) {
router.push({ name: 'Courses' })
}
}
const breadcrumbs = computed(() => {
let crumbs = [
{

View File

@@ -43,7 +43,7 @@
<div
v-show="openInstructorEditor"
id="instructor-notes"
class="py-3"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-6 py-3"
></div>
</div>
</div>
@@ -52,7 +52,10 @@
<label class="block font-medium text-gray-600 mb-1">
{{ __('Content') }}
</label>
<div id="content" class="py-3"></div>
<div
id="content"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-6 py-3"
></div>
</div>
</div>
</div>

View File

@@ -18,12 +18,16 @@
}}
</p>
<router-link
v-if="user.data"
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
>
<Button variant="solid">
{{ __('Start Learning') }}
</Button>
</router-link>
<Button v-else @click="redirectToLogin()">
{{ __('Login') }}
</Button>
</div>
<div v-else class="border-r container pt-5 pb-10 px-5">
<div class="flex flex-col md:flex-row md:items-center justify-between">
@@ -151,7 +155,7 @@
</div>
</div>
<div class="sticky top-10">
<div class="bg-gray-50 py-5 pl-2 border-b">
<div class="bg-gray-50 py-5 px-2 border-b">
<div class="text-lg font-semibold">
{{ lesson.data.course_title }}
</div>
@@ -170,7 +174,11 @@
></div>
</div>
</div>
<CourseOutline :courseName="courseName" :key="chapterNumber" />
<CourseOutline
:courseName="courseName"
:key="chapterNumber"
:getProgress="lesson.data.membership ? true : false"
/>
</div>
</div>
</div>
@@ -328,6 +336,10 @@ const allowInstructorContent = () => {
if (lesson.data?.instructors.includes(user.data?.name)) return true
return false
}
const redirectToLogin = () => {
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
}
</script>
<style>
.avatar-group {

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>
</div>
</div>
<div class="mx-auto -mt-4 max-w-4xl translate-x-0 sm:px-5">
<div class="flex items-center">
<div class="mx-auto -mt-10 md:-mt-4 max-w-4xl translate-x-0 px-5">
<div class="flex flex-col md:flex-row items-center">
<div>
<img
v-if="profile.data.user_image"
@@ -57,7 +57,11 @@
{{ profile.data.headline }}
</div>
</div>
<Button v-if="isSessionUser()" class="ml-auto" @click="editProfile()">
<Button
v-if="isSessionUser()"
class="mt-3 sm:mt-0 md:ml-auto"
@click="editProfile()"
>
<template #prefix>
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
</template>

View File

@@ -1,5 +1,5 @@
<template>
<div class="mt-7">
<div class="mt-7 mb-10">
<h2 class="mb-3 text-lg font-semibold text-gray-900">
{{ __('About') }}
</h2>
@@ -12,12 +12,92 @@
{{ __('No introduction') }}
</div>
</div>
<div class="mt-7 mb-10">
<h2 class="mb-3 text-lg font-semibold text-gray-900">
{{ __('Achievements') }}
</h2>
<div class="grid grid-cols-5 gap-4">
<div v-if="badges.data" v-for="badge in badges.data">
<Popover trigger="hover">
<template #target>
<div class="relative">
<img
:src="badge.badge_image"
:alt="badge.badge"
class="h-[80px]"
/>
<div
v-if="badge.count > 1"
class="flex items-end bg-gray-100 p-2 text-xs font-semibold rounded-full absolute right-0 bottom-0"
>
<span>
<X class="w-3 h-3" />
</span>
{{ badge.count }}
</div>
</div>
</template>
<template #body-main>
<div class="w-[250px] text-base">
<img
:src="badge.badge_image"
:alt="badge.badge"
class="bg-gray-100 rounded-t-md"
/>
<div class="p-5">
<div class="text-2xl font-semibold mb-2">
{{ badge.badge }}
</div>
<div class="leading-5 mb-4">
{{ badge.badge_description }}
</div>
<div class="flex flex-col">
<span class="text-xs text-gray-700 font-medium mb-1">
{{ __('Issued on') }}:
</span>
{{ dayjs(badge.issued_on).format('DD MMM YYYY') }}
</div>
</div>
</div>
</template>
</Popover>
</div>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue'
import { createResource, Popover } from 'frappe-ui'
import { X } from 'lucide-vue-next'
const dayjs = inject('$dayjs')
const props = defineProps({
profile: {
type: Object,
required: true,
},
})
const badges = createResource({
url: 'frappe.client.get_list',
params: {
doctype: 'LMS Badge Assignment',
fields: ['name', 'badge', 'badge_image', 'badge_description', 'issued_on'],
filters: {
member: props.profile.data.name,
},
},
auto: true,
transform(data) {
let finalBadges = []
let groupedBadges = Object.groupBy(data, ({ badge }) => badge)
for (let badge in groupedBadges) {
let badgeData = groupedBadges[badge][0]
badgeData.count = groupedBadges[badge].length
finalBadges.push(badgeData)
}
return finalBadges
},
})
</script>

View File

@@ -1,9 +1,9 @@
<template>
<div class="mt-7">
<div class="mt-7 mb-10">
<h2 class="mb-3 text-lg font-semibold text-gray-900">
{{ __('Certificates') }}
</h2>
<div class="grid grid-cols-3 gap-4">
<div class="grid grod-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="certificate in certificates.data"
:key="certificate.name"
@@ -34,13 +34,9 @@ const props = defineProps({
})
const certificates = createResource({
url: 'frappe.client.get_list',
url: 'lms.lms.api.get_certificates',
params: {
doctype: 'LMS Certificate',
fields: ['name', 'course', 'course_title', 'issue_date', 'template'],
filters: {
member: props.profile.data.name,
},
member: props.profile.data.name,
},
auto: true,
})

View File

@@ -5,7 +5,9 @@
</h2>
<div class="">
<div class="grid grid-cols-4 gap-4 text-sm text-gray-700 mb-4">
<div
class="grid grid-cols-3 md:grid-cols-4 gap-4 text-sm text-gray-700 mb-4"
>
<div>
{{ __('Day') }}
</div>
@@ -20,7 +22,7 @@
<div
v-if="evaluator.data"
v-for="slot in evaluator.data.slots.schedule"
class="grid grid-cols-4 gap-4 mb-4 group"
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4 group"
>
<FormControl
type="select"
@@ -44,7 +46,10 @@
/>
</div>
<div class="grid grid-cols-4 gap-4 mb-4" v-show="showSlotsTemplate">
<div
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4"
v-show="showSlotsTemplate"
>
<FormControl
type="select"
:options="days"
@@ -74,7 +79,7 @@
<h2 class="mb-4 text-lg font-semibold text-gray-900">
{{ __('I am unavailable') }}
</h2>
<div class="grid grid-cols-4 gap-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<FormControl
type="date"
:label="__('From')"

View File

@@ -3,7 +3,9 @@
<h2 class="mb-3 text-lg font-semibold text-gray-900">
{{ __('Settings') }}
</h2>
<div class="flex justify-between w-3/4 mt-5">
<div
class="flex flex-col md:flex-row gap-4 md:gap-0 justify-between w-3/4 mt-5"
>
<FormControl
:label="__('Moderator')"
v-model="moderator"

View File

@@ -6,7 +6,7 @@
<Breadcrumbs class="h-7" :items="breadcrumbs" />
</header>
<div v-if="chartDetails.data" class="p-5">
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<div class="flex items-center shadow py-2 px-3 rounded-md">
<div class="p-2 rounded-md bg-gray-100 mr-3">
<BookOpen class="w-18 h-18 stroke-1.5 text-gray-700" />

View File

@@ -130,6 +130,11 @@ const routes = [
name: 'CertifiedParticipants',
component: () => import('@/pages/CertifiedParticipants.vue'),
},
{
path: '/notifications',
name: 'Notifications',
component: () => import('@/pages/Notifications.vue'),
},
]
let router = createRouter({
@@ -138,13 +143,21 @@ let router = createRouter({
})
router.beforeEach(async (to, from, next) => {
const { userResource } = usersStore()
const { userResource, allUsers } = usersStore()
let { isLoggedIn } = sessionStore()
try {
if (isLoggedIn) {
await userResource.reload()
}
if (
isLoggedIn &&
(to.name == 'Lesson' ||
to.name == 'Batch' ||
to.name == 'Notifications')
) {
await allUsers.reload()
}
} catch (error) {
isLoggedIn = false
}

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 {
userResource,
allUsers,
}
})

View File

@@ -6,6 +6,7 @@ import {
TrendingUp,
Briefcase,
GraduationCap,
Bell,
} from 'lucide-vue-next'
import { Quiz } from '@/utils/quiz'
import { Upload } from '@/utils/upload'
@@ -350,6 +351,7 @@ export function getSidebarLinks() {
label: 'Statistics',
icon: TrendingUp,
to: 'Statistics',
activeFor: ['Statistics'],
},
]
}

View File

@@ -43,7 +43,6 @@ export class Quiz {
}
save(blockContent) {
console.log(blockContent)
return {
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
doc_events = {
"*": {
"on_change": [
"lms.lms.doctype.lms_badge.lms_badge.process_badges",
]
},
"Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"},
"Notification Log": {"on_change": "lms.lms.utils.publish_notifications"},
}
# Scheduled Tasks
@@ -108,7 +114,7 @@ scheduler_events = {
]
}
fixtures = ["Custom Field", "Function", "Industry"]
fixtures = ["Custom Field", "Function", "Industry", "LMS Badge"]
# Testing
# -------
@@ -146,9 +152,8 @@ website_redirects = [
{"source": "/update-profile", "target": "/edit-profile"},
{"source": "/courses", "target": "/lms/courses"},
{
"source": r"/courses/([^/]*)",
"source": r"^/courses/.*$",
"target": "/lms/courses",
"match_with_query_string": True,
},
{"source": "/batches", "target": "/lms/batches"},
{
@@ -232,7 +237,8 @@ jinja = {
# ]
has_website_permission = {
"LMS Certificate Evaluation": "lms.lms.doctype.lms_certificate_evaluation.lms_certificate_evaluation.has_website_permission"
"LMS Certificate Evaluation": "lms.lms.doctype.lms_certificate_evaluation.lms_certificate_evaluation.has_website_permission",
"LMS Certificate": "lms.lms.doctype.lms_certificate.lms_certificate.has_website_permission",
}
profile_mandatory_fields = [
@@ -270,6 +276,7 @@ lms_markdown_macro_renderers = {
page_renderer = [
"lms.page_renderers.ProfileRedirectPage",
"lms.page_renderers.ProfilePage",
"lms.page_renderers.CoursePage",
]
# set this to "/" to have profiles on the top-level

View File

@@ -330,12 +330,13 @@ def get_evaluator_details(evaluator):
@frappe.whitelist(allow_guest=True)
def get_certified_participants():
def get_certified_participants(search_query=""):
LMSCertificate = DocType("LMS Certificate")
participants = (
frappe.qb.from_(LMSCertificate)
.select(LMSCertificate.member)
.distinct()
.where(LMSCertificate.member_name.like(f"%{search_query}%"))
.where(LMSCertificate.published == 1)
.orderby(LMSCertificate.creation, order=frappe.qb.desc)
.run(as_dict=1)
@@ -355,7 +356,62 @@ def get_certified_participants():
courses = []
for course in course_names:
courses.append(frappe.db.get_value("LMS Course", course, "title"))
details.courses = courses
details["courses"] = courses
participant_details.append(details)
return participant_details
@frappe.whitelist()
def get_assigned_badges(member):
assigned_badges = frappe.get_all(
"LMS Badge Assignment",
{"member": member},
["badge"],
as_dict=1,
)
for badge in assigned_badges:
badge.update(
frappe.db.get_value("LMS Badge", badge.badge, ["name", "title", "image"])
)
return assigned_badges
def get_certificates(member):
"""Get certificates for a member."""
return frappe.get_all(
"LMS Certificate",
filters={"member": member},
fields=["name", "course", "course_title", "issue_date", "template"],
order_by="creation desc",
)
@frappe.whitelist()
def get_all_users():
users = frappe.get_all(
"User",
{
"enabled": 1,
},
["name", "full_name", "user_image"],
)
return {user.name: user for user in users}
@frappe.whitelist()
def mark_as_read(name):
doc = frappe.get_doc("Notification Log", name)
doc.read = 1
doc.save(ignore_permissions=True)
@frappe.whitelist()
def mark_all_as_read():
notifications = frappe.get_all(
"Notification Log", {"for_user": frappe.session.user, "read": 0}, pluck="name"
)
for notification in notifications:
mark_as_read(notification)

View File

@@ -15,12 +15,6 @@ class CourseEvaluator(Document):
self.validate_unavailability()
def validate_unavailability(self):
if self.unavailable_from and not self.unavailable_to:
frappe.throw(_("Unavailable To Date is mandatory if Unavailable From Date is set"))
if self.unavailable_to and not self.unavailable_from:
frappe.throw(_("Unavailable From Date is mandatory if Unavailable To Date is set"))
if (
self.unavailable_from
and self.unavailable_to

View File

@@ -7,6 +7,7 @@ from frappe.model.document import Document
from frappe.utils.telemetry import capture
from lms.lms.utils import get_course_progress
from ...md import find_macros
import json
class CourseLesson(Document):
@@ -89,7 +90,7 @@ class CourseLesson(Document):
@frappe.whitelist()
def save_progress(lesson, course):
membership = frappe.db.exists(
"LMS Enrollment", {"member": frappe.session.user, "course": course}
"LMS Enrollment", {"course": course, "member": frappe.session.user}
)
if not membership:
return 0
@@ -114,23 +115,52 @@ def save_progress(lesson, course):
progress = get_course_progress(course)
frappe.db.set_value("LMS Enrollment", membership, "progress", progress)
enrollment = frappe.get_doc("LMS Enrollment", membership)
enrollment.run_method("on_change")
return progress
def get_quiz_progress(lesson):
body = frappe.db.get_value("Course Lesson", lesson, "body")
macros = find_macros(body)
quizzes = [value for name, value in macros if name == "Quiz"]
lesson_details = frappe.db.get_value(
"Course Lesson", lesson, ["body", "content"], as_dict=1
)
quizzes = []
if lesson_details.content:
content = json.loads(lesson_details.content)
for block in content.get("blocks"):
if block.get("type") == "quiz":
quizzes.append(block.get("data").get("quiz"))
elif lesson_details.body:
macros = find_macros(lesson_details.body)
quizzes = [value for name, value in macros if name == "Quiz"]
for quiz in quizzes:
print(quiz)
passing_percentage = frappe.db.get_value("LMS Quiz", quiz, "passing_percentage")
print(frappe.session.user)
print(passing_percentage)
print(
frappe.db.exists(
"LMS Quiz Submission",
{
"quiz": quiz,
"member": frappe.session.user,
"percentage": [">=", passing_percentage],
},
)
)
if not frappe.db.exists(
"LMS Quiz Submission",
{
"quiz": quiz,
"owner": frappe.session.user,
"member": frappe.session.user,
"percentage": [">=", passing_percentage],
},
):
print("no submission")
return False
return True

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": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "format: CLS-{#####}",
"creation": "2022-11-09 16:14:05.876933",
@@ -304,7 +305,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-04-17 10:35:21.957961",
"modified": "2024-05-14 14:47:48.839162",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Batch",
@@ -352,5 +353,6 @@
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "title"
"title_field": "title",
"track_changes": 1
}

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_import": 1,
"creation": "2021-08-16 15:47:19.494055",
"doctype": "DocType",
"editable_grid": 1,
@@ -87,7 +88,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-04-09 13:42:18.350028",
"modified": "2024-05-14 14:48:31.650107",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Certificate",
@@ -116,6 +117,15 @@
"role": "Moderator",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
}
],
"sort_field": "modified",

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()
def create_certificate(course):
certificate = is_certified(course)
@@ -91,7 +97,13 @@ def create_certificate(course):
},
"value",
)
if not default_certificate_template:
default_certificate_template = frappe.db.get_value(
"Print Format",
{
"doc_type": "LMS Certificate",
},
)
certificate = frappe.get_doc(
{
"doctype": "LMS Certificate",

View File

@@ -76,8 +76,7 @@
{
"fieldname": "video_link",
"fieldtype": "Data",
"label": "Video Embed Link",
"reqd": 1
"label": "Video Embed Link"
},
{
"fieldname": "short_introduction",
@@ -274,7 +273,7 @@
}
],
"make_attachments_public": 1,
"modified": "2024-04-16 17:40:50.899368",
"modified": "2024-05-08 15:11:07.833094",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Course",
@@ -305,7 +304,6 @@
"write": 1
}
],
"search_fields": "title",
"show_title_field_in_link": 1,
"sort_field": "creation",
"sort_order": "DESC",

View File

@@ -5,7 +5,7 @@ import json
import random
import frappe
from frappe.model.document import Document
from frappe.utils import cint
from frappe.utils import cint, today
from frappe.utils.telemetry import capture
from lms.lms.utils import get_chapters, can_create_courses
from ...utils import generate_slug, validate_image
@@ -14,11 +14,16 @@ from frappe import _
class LMSCourse(Document):
def validate(self):
self.validate_published()
self.validate_instructors()
self.validate_video_link()
self.validate_status()
self.image = validate_image(self.image)
def validate_published(self):
if self.published and not self.published_on:
self.published_on = today()
def validate_instructors(self):
if self.is_new() and not self.instructors:
frappe.get_doc(

View File

@@ -72,6 +72,8 @@ def new_course(title, additional_filters=None):
"title": title,
"short_introduction": title,
"description": title,
"video_link": "https://youtu.be/pEbIhUySqbk",
"image": "/assets/lms/images/course-home.png",
}
if additional_filters:

View File

@@ -1,9 +1,20 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
# import frappe
import frappe
from frappe.model.document import Document
from lms.lms.utils import get_course_progress
class LMSCourseProgress(Document):
pass
def after_delete(self):
progress = get_course_progress(self.course, self.member)
membership = frappe.db.get_value(
"LMS Enrollment",
{
"member": self.member,
"course": self.course,
},
"name",
)
frappe.db.set_value("LMS Enrollment", membership, "progress", progress)

View File

@@ -1,13 +1,15 @@
{
"actions": [],
"allow_import": 1,
"creation": "2022-02-07 12:01:40.929633",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"course",
"member_type",
"progress",
"payment",
"current_lesson",
"column_break_3",
"member",
"member_name",
@@ -17,8 +19,7 @@
"subgroup",
"batch_old",
"column_break_12",
"current_lesson",
"progress",
"member_type",
"role"
],
"fields": [
@@ -113,7 +114,8 @@
},
{
"fieldname": "section_break_8",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"hidden": 1
},
{
"fieldname": "payment",
@@ -124,7 +126,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-03-18 17:32:30.182301",
"modified": "2024-05-14 14:50:08.405033",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Enrollment",
@@ -173,5 +175,6 @@
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "member_name"
"title_field": "member_name",
"track_changes": 1
}

View File

@@ -103,6 +103,7 @@ def quiz_summary(quiz, results):
"passing_percentage": quiz_details.passing_percentage,
}
)
submission.save(ignore_permissions=True)
if (
percentage >= quiz_details.passing_percentage
@@ -110,8 +111,8 @@ def quiz_summary(quiz, results):
and quiz_details.course
):
save_progress(quiz_details.lesson, quiz_details.course)
submission.save(ignore_permissions=True)
elif not quiz_details.passing_percentage:
save_progress(quiz_details.lesson, quiz_details.course)
return {
"score": score,

View File

@@ -23,7 +23,7 @@
},
{
"fieldname": "answer",
"fieldtype": "Data",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "Users Response",
"read_only": 1
@@ -61,7 +61,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-10-17 11:55:25.641214",
"modified": "2024-05-17 17:38:51.760653",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Quiz Result",

View File

@@ -1,5 +1,5 @@
{% set title = frappe.db.get_value("LMS Course", doc.course, "title") %}
<p> {{ _('Your evaluation for the course ${0} has been scheduled on ${1} at ${2}.').format(title, frappe.utils.format_date(doc.date, "medium"), frappe.utils.format_time(doc.start_time, "short")) }}</p>
<p> {{ _('Your evaluation for the course {0} has been scheduled on {1} at {2}.').format(title, frappe.utils.format_date(doc.date, "medium"), frappe.utils.format_time(doc.start_time, "short")) }}</p>
<p> {{ _("Please prepare well and be on time for the evaluations.") }} </p>

View File

@@ -115,27 +115,27 @@ def get_chapters(course):
return chapters
def get_lessons(course, chapter=None, get_details=True):
def get_lessons(course, chapter=None, get_details=True, progress=False):
"""If chapter is passed, returns lessons of only that chapter.
Else returns lessons of all chapters of the course"""
lessons = []
lesson_count = 0
if chapter:
if get_details:
return get_lesson_details(chapter)
return get_lesson_details(chapter, progress=progress)
else:
return frappe.db.count("Lesson Reference", {"parent": chapter.name})
for chapter in get_chapters(course):
if get_details:
lessons += get_lesson_details(chapter)
lessons += get_lesson_details(chapter, progress=progress)
else:
lesson_count += frappe.db.count("Lesson Reference", {"parent": chapter.name})
return lessons if get_details else lesson_count
def get_lesson_details(chapter):
def get_lesson_details(chapter, progress=False):
lessons = []
lesson_list = frappe.get_all(
"Lesson Reference", {"parent": chapter.name}, ["lesson", "idx"], order_by="idx"
@@ -161,6 +161,10 @@ def get_lesson_details(chapter):
)
lesson_details.number = f"{chapter.idx}.{row.idx}"
lesson_details.icon = get_lesson_icon(lesson_details.body)
if progress:
lesson_details.is_complete = get_progress(lesson_details.course, lesson_details.name)
lessons.append(lesson_details)
return lessons
@@ -277,21 +281,21 @@ def get_lesson_index(lesson_name):
"Lesson Reference", {"lesson": lesson_name}, ["idx", "parent"], as_dict=True
)
if not lesson:
return "1.1"
return "1-1"
chapter = frappe.db.get_value(
"Chapter Reference", {"chapter": lesson.parent}, ["idx"], as_dict=True
)
if not chapter:
return "1.1"
return "1-1"
return f"{chapter.idx}.{lesson.idx}"
return f"{chapter.idx}-{lesson.idx}"
def get_lesson_url(course, lesson_number):
if not lesson_number:
return
return f"/lms/courses/{course}/learn/{lesson_number}"
return f"/courses/{course}/learn/{lesson_number}"
def get_batch(course, batch_name):
@@ -306,7 +310,7 @@ def get_progress(course, lesson, member=None):
if not member:
member = frappe.session.user
return frappe.db.get_value(
return frappe.db.exists(
"LMS Course Progress",
{"course": course, "member": member, "lesson": lesson},
["status"],
@@ -379,7 +383,7 @@ def get_course_progress(course, member=None):
return 0
completed_lessons = frappe.db.count(
"LMS Course Progress",
{"course": course, "owner": member or frappe.session.user, "status": "Complete"},
{"course": course, "member": member or frappe.session.user, "status": "Complete"},
)
precision = cint(frappe.db.get_default("float_precision")) or 3
return flt(((completed_lessons / lesson_count) * 100), precision)
@@ -636,37 +640,91 @@ def handle_notifications(doc, method):
if topic.reference_doctype not in ["Course Lesson", "LMS Batch"]:
return
create_notification_log(doc, topic)
notify_mentions(doc, topic)
notify_mentions_on_portal(doc, topic)
notify_mentions_via_email(doc, topic)
def create_notification_log(doc, topic):
course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course")
instructors = frappe.db.get_all(
"Course Instructor", {"parent": course}, pluck="instructor"
)
users = []
if topic.reference_doctype == "LMS Course":
course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course")
course_title = frappe.db.get_value("LMS Course", course, "title")
instructors = frappe.db.get_all(
"Course Instructor", {"parent": course}, pluck="instructor"
)
users.append(topic.owner)
users += instructors
subject = _("New reply on the topic {0} in course {1}").format(
topic.title, course_title
)
link = get_lesson_url(course, get_lesson_index(topic.reference_docname))
else:
batch_title = frappe.db.get_value("LMS Batch", topic.reference_docname, "title")
subject = _("New comment in batch {0}").format(batch_title)
link = f"/batches/{topic.reference_docname}"
moderators = frappe.get_all("Has Role", {"role": "Moderator"}, pluck="parent")
users += moderators
notification = frappe._dict(
{
"subject": _("New reply on the topic {0}").format(topic.title),
"subject": subject,
"email_content": doc.reply,
"document_type": topic.reference_doctype,
"document_name": topic.reference_docname,
"for_user": topic.owner,
"from_user": doc.owner,
"type": "Alert",
"link": link,
}
)
users = []
if doc.owner != topic.owner:
users.append(topic.owner)
if doc.owner not in instructors:
users += instructors
make_notification_logs(notification, users)
def notify_mentions(doc, topic):
def notify_mentions_on_portal(doc, topic):
mentions = extract_mentions(doc.reply)
if not mentions:
return
from_user_name = get_fullname(doc.owner)
if topic.reference_doctype == "LMS Course":
course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course")
subject = _("{0} mentioned you in a comment in {1}").format(
from_user_name, topic.title
)
link = get_lesson_url(course, get_lesson_index(topic.reference_docname))
else:
batch_title = frappe.db.get_value("LMS Batch", topic.reference_docname, "title")
subject = _("{0} mentioned you in a comment in {1}").format(
from_user_name, batch_title
)
link = f"/batches/{topic.reference_docname}"
for user in mentions:
notification = frappe._dict(
{
"subject": subject,
"email_content": doc.reply,
"document_type": topic.reference_doctype,
"document_name": topic.reference_docname,
"for_user": user,
"from_user": doc.owner,
"type": "Alert",
"link": link,
}
)
make_notification_logs(notification, user)
def notify_mentions_via_email(doc, topic):
outgoing_email_account = frappe.get_cached_value(
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
)
if not outgoing_email_account or not frappe.conf.get("mail_login"):
return
mentions = extract_mentions(doc.reply)
if not mentions:
return
@@ -1208,6 +1266,7 @@ def get_course_details(course):
"short_introduction",
"published",
"upcoming",
"disable_self_learning",
"published_on",
"status",
"paid_course",
@@ -1299,7 +1358,7 @@ def get_categorized_courses(courses):
@frappe.whitelist(allow_guest=True)
def get_course_outline(course):
def get_course_outline(course, progress=False):
"""Returns the course outline."""
outline = []
chapters = frappe.get_all(
@@ -1313,7 +1372,7 @@ def get_course_outline(course):
as_dict=True,
)
chapter_details["idx"] = chapter.idx
chapter_details.lessons = get_lessons(course, chapter_details)
chapter_details.lessons = get_lessons(course, chapter_details, progress=progress)
outline.append(chapter_details)
return outline
@@ -1519,10 +1578,12 @@ def get_question_details(question):
@frappe.whitelist(allow_guest=True)
def get_batch_courses(batch):
courses = []
course_list = frappe.get_all("Batch Course", {"parent": batch}, pluck="course")
course_list = frappe.get_all("Batch Course", {"parent": batch}, ["name", "course"])
for course in course_list:
courses.append(get_course_details(course))
details = get_course_details(course.course)
details.batch_course = course.name
courses.append(details)
return courses
@@ -1701,6 +1762,7 @@ def create_discussion_topic(doctype, docname):
doc = frappe.new_doc("Discussion Topic")
doc.update(
{
"title": docname,
"reference_doctype": doctype,
"reference_docname": docname,
}
@@ -1810,3 +1872,9 @@ def get_roles(name):
"batch_evaluator": has_course_evaluator_role(name),
"lms_student": has_student_role(name),
}
def publish_notifications(doc, method):
frappe.publish_realtime(
"publish_lms_notifications", user=doc.for_user, after_commit=True
)

View File

@@ -9,8 +9,9 @@
"label": "Enrollments"
}
],
"content": "[{\"id\":\"jNO4sdKxHu\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Get Started</b></span>\",\"col\":12}},{\"id\":\"5s0qRBc4rY\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/courses\\\" draggable=\\\"false\\\">Visit LMS Portal</a>\",\"col\":4}},{\"id\":\"lGMuNLpmv-\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/courses/new-course/edit\\\">Create a Course</a>\",\"col\":4}},{\"id\":\"3TVyc9AkPy\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/app/web-page/new-web-page-1\\\">Setup a Home Page</a>\",\"col\":4}},{\"id\":\"9zcbqpu2gm\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/app/lms-settings/LMS%20Settings\\\">LMS Setting</a>\",\"col\":4}},{\"id\":\"0ATmnKmXjc\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"https://docs.frappelms.com\\\">Documentation</a>\",\"col\":4}},{\"id\":\"7tGB2TYPmn\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"https://frappe.school/courses/introducing-frappe-lms\\\">Video Tutorials</a>\",\"col\":4}},{\"id\":\"C128a4abjX\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"5q4sPiv2ci\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Signups\",\"col\":6}},{\"id\":\"8NSaRaEV5u\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Enrollments\",\"col\":6}},{\"id\":\"kMuzko0uAU\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"iuvIOHmztI\",\"type\":\"header\",\"data\":{\"text\":\"<span style=\\\"font-size: 18px;\\\"><b>Statistics</b></span>\",\"col\":12}},{\"id\":\"l0VTd66Uy2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Users\",\"col\":4}},{\"id\":\"wAWZin1KKk\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Course\",\"col\":4}},{\"id\":\"RLrIlFx0Hd\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Enrollments\",\"col\":4}},{\"id\":\"OuhWkhCQmq\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Course Completed\",\"col\":4}},{\"id\":\"3g8QmNqUXG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Certificate\",\"col\":4}},{\"id\":\"EZsdsujs8N\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Evaluation\",\"col\":4}},{\"id\":\"s-nfsFQbGV\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"jeOBWBzHEa\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Master</b></span>\",\"col\":12}},{\"id\":\"sVhgfS5GIh\",\"type\":\"card\",\"data\":{\"card_name\":\"Course Data\",\"col\":4}},{\"id\":\"Iea0snm4Fg\",\"type\":\"card\",\"data\":{\"card_name\":\"Course Stats\",\"col\":4}},{\"id\":\"bZB7RqOl6a\",\"type\":\"card\",\"data\":{\"card_name\":\"Certification\",\"col\":4}}]",
"content": "[{\"id\":\"jNO4sdKxHu\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Get Started</b></span>\",\"col\":12}},{\"id\":\"5s0qRBc4rY\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/lms/courses\\\">Visit LMS Portal</a>\",\"col\":4}},{\"id\":\"lGMuNLpmv-\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/courses/new-course/edit\\\">Create a Course</a>\",\"col\":4}},{\"id\":\"3TVyc9AkPy\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/app/web-page/new-web-page-1\\\">Setup a Home Page</a>\",\"col\":4}},{\"id\":\"9zcbqpu2gm\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/app/lms-settings/LMS%20Settings\\\">LMS Setting</a>\",\"col\":4}},{\"id\":\"0ATmnKmXjc\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"https://docs.frappelms.com\\\">Documentation</a>\",\"col\":4}},{\"id\":\"7tGB2TYPmn\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"https://frappe.school/courses/introducing-frappe-lms\\\">Video Tutorials</a>\",\"col\":4}},{\"id\":\"C128a4abjX\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"5q4sPiv2ci\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Signups\",\"col\":6}},{\"id\":\"8NSaRaEV5u\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Enrollments\",\"col\":6}},{\"id\":\"kMuzko0uAU\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"iuvIOHmztI\",\"type\":\"header\",\"data\":{\"text\":\"<span style=\\\"font-size: 18px;\\\"><b>Statistics</b></span>\",\"col\":12}},{\"id\":\"l0VTd66Uy2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Users\",\"col\":4}},{\"id\":\"wAWZin1KKk\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Course\",\"col\":4}},{\"id\":\"RLrIlFx0Hd\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Enrollments\",\"col\":4}},{\"id\":\"OuhWkhCQmq\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Course Completed\",\"col\":4}},{\"id\":\"3g8QmNqUXG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Certificate\",\"col\":4}},{\"id\":\"EZsdsujs8N\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Evaluation\",\"col\":4}},{\"id\":\"s-nfsFQbGV\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"jeOBWBzHEa\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Master</b></span>\",\"col\":12}},{\"id\":\"sVhgfS5GIh\",\"type\":\"card\",\"data\":{\"card_name\":\"Course Data\",\"col\":4}},{\"id\":\"Iea0snm4Fg\",\"type\":\"card\",\"data\":{\"card_name\":\"Course Stats\",\"col\":4}},{\"id\":\"bZB7RqOl6a\",\"type\":\"card\",\"data\":{\"card_name\":\"Certification\",\"col\":4}}]",
"creation": "2021-10-21 17:20:01.358903",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"hide_custom": 0,
@@ -144,7 +145,7 @@
"type": "Link"
}
],
"modified": "2023-05-11 15:41:25.514443",
"modified": "2024-05-09 14:44:08.590606",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS",

View File

@@ -107,3 +107,34 @@ def render_portal_page(path, **kwargs):
frappe.form_dict.update(kwargs)
page = TemplatePage(path)
return page.render()
class CoursePage(BaseRenderer):
def __init__(self, path, http_status_code):
super().__init__(path, http_status_code)
self.renderer = None
def can_render(self):
return self.path.startswith("course")
def render(self):
if "learn" in self.path:
prefix = self.path.split("/learn")[0]
course_name = prefix.split("/")[1]
lesson_index = self.path.split("/learn/")[1]
chapter_number = lesson_index.split(".")[0]
lesson_number = lesson_index.split(".")[1]
frappe.flags.redirect_location = (
f"/lms/courses/{course_name}/learn/{chapter_number}-{lesson_number}"
)
return RedirectPage(self.path).render()
elif len(self.path.split("/")) > 1:
course_name = self.path.split("/")[1]
frappe.flags.redirect_location = f"/lms/courses/{course_name}"
return RedirectPage(self.path).render()
else:
frappe.flags.redirect_location = "/lms/courses"
return RedirectPage(self.path).render()

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.rename_evaluator_role
lms.patches.v1_0.change_navbar_urls
lms.patches.v1_0.set_published_on
lms.patches.v1_0.set_published_on
lms.patches.v2_0.fix_progress_percentage
lms.patches.v2_0.add_discussion_topic_titles

View File

@@ -2,5 +2,7 @@ import frappe
def execute():
if frappe.db.exists("Role", "Class Evaluator"):
if frappe.db.exists("Role", "Class Evaluator") and not frappe.db.exists(
"Role", "Batch Evaluator"
):
frappe.rename_doc("Role", "Class Evaluator", "Batch Evaluator")

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" />
<link rel="icon" href="/assets/lms/frontend/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Frappe UI App</title>
<title>Frappe Learning</title>
<meta name="title" content="{{ meta.title }}" />
<meta name="image" content="{{ meta.image }}" />
<meta name="description" content="{{ meta.description }}" />
@@ -15,10 +15,10 @@
<meta name="twitter:title" content="{{ meta.title }}" />
<meta name="twitter:image" content="{{ meta.image }}" />
<meta name="twitter:description" content="{{ meta.description }}" />
<script type="module" crossorigin src="/assets/lms/frontend/assets/index-B0I4dIsL.js"></script>
<link rel="modulepreload" crossorigin href="/assets/lms/frontend/assets/frappe-ui-BlL1CpdE.js">
<script type="module" crossorigin src="/assets/lms/frontend/assets/index-C-DogOtg.js"></script>
<link rel="modulepreload" crossorigin href="/assets/lms/frontend/assets/frappe-ui-CGsuCsfq.js">
<link rel="stylesheet" crossorigin href="/assets/lms/frontend/assets/frappe-ui-B1gEXx4C.css">
<link rel="stylesheet" crossorigin href="/assets/lms/frontend/assets/index-wBsCm0D8.css">
<link rel="stylesheet" crossorigin href="/assets/lms/frontend/assets/index-C1pDkvO9.css">
</head>
<body>
<div id="app">

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
url = f"{base_url}{path}"
print(url)
res = requests.get(
url,
headers={

View File

@@ -32,6 +32,14 @@ def get_meta(app_path):
}
if re.match(r"^courses/.*$", app_path):
if "new/edit" in app_path:
return {
"title": _("New Course"),
"image": frappe.db.get_single_value("Website Settings", "banner_image"),
"description": "Create a new course",
"keywords": "New Course, Create Course",
"link": "/lms/courses/new/edit",
}
course_name = app_path.split("/")[1]
course = frappe.db.get_value(
"LMS Course",
@@ -56,7 +64,6 @@ def get_meta(app_path):
"link": "/batches",
}
if re.match(r"^batches/details/.*$", app_path):
print(app_path, "app_path")
batch_name = app_path.split("/")[2]
batch = frappe.db.get_value(
"LMS Batch",
@@ -90,7 +97,7 @@ def get_meta(app_path):
as_dict=True,
)
return {
"title": job_opening.title,
"title": job_opening.job_title,
"image": job_opening.company_logo,
"description": job_opening.company_name,
"keywords": "Job Openings, Jobs, Vacancies",

View File

@@ -22,5 +22,9 @@
"bugs": {
"url": "https://github.com/frappe/lms/issues"
},
"homepage": "https://github.com/frappe/lms#readme"
}
"homepage": "https://github.com/frappe/lms#readme",
"devDependencies": {
"cypress": "^13.9.0",
"cypress-file-upload": "^5.0.8"
}
}

4501
yarn.lock Normal file

File diff suppressed because it is too large Load Diff