chore: fixed merge conflicts

This commit is contained in:
Jannat Patel
2025-12-08 14:54:14 +05:30
110 changed files with 26439 additions and 11302 deletions
+2 -2
View File
@@ -34,9 +34,9 @@ jobs:
with: with:
python-version: '3.10' python-version: '3.10'
- name: setup node - name: setup node
uses: actions/setup-node@v2 uses: actions/setup-node@v4
with: with:
node-version: '18' node-version: '20'
check-latest: true check-latest: true
- name: setup cache for bench - name: setup cache for bench
uses: actions/cache@v4 uses: actions/cache@v4
+2 -2
View File
@@ -48,9 +48,9 @@ jobs:
exit 1 exit 1
fi fi
- uses: actions/setup-node@v3 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 20
check-latest: true check-latest: true
- name: Add to Hosts - name: Add to Hosts
+1
View File
@@ -52,6 +52,7 @@ describe("Batch Creation", () => {
// Create a batch // Create a batch
cy.get("button").contains("Create").click(); cy.get("button").contains("Create").click();
cy.get("span").contains("New Batch").click();
cy.wait(500); cy.wait(500);
cy.url().should("include", "/batches/new/edit"); cy.url().should("include", "/batches/new/edit");
cy.get("label").contains("Title").type("Test Batch"); cy.get("label").contains("Title").type("Test Batch");
+2
View File
@@ -9,6 +9,7 @@ describe("Course Creation", () => {
// Create a course // Create a course
cy.get("button").contains("Create").click(); cy.get("button").contains("Create").click();
cy.get("span").contains("New Course").click();
cy.wait(500); cy.wait(500);
cy.url().should("include", "/courses/new/edit"); cy.url().should("include", "/courses/new/edit");
@@ -55,6 +56,7 @@ describe("Course Creation", () => {
.parent() .parent()
.within(() => { .within(() => {
cy.get("input").click().type("frappe"); cy.get("input").click().type("frappe");
cy.wait(500);
cy.get("input") cy.get("input")
.invoke("attr", "aria-controls") .invoke("attr", "aria-controls")
.as("instructor_list_id"); .as("instructor_list_id");
+5 -4
View File
@@ -11,8 +11,8 @@ declare module 'vue' {
AdminBatchDashboard: typeof import('./src/components/AdminBatchDashboard.vue')['default'] AdminBatchDashboard: typeof import('./src/components/AdminBatchDashboard.vue')['default']
Annoucements: typeof import('./src/components/Annoucements.vue')['default'] Annoucements: typeof import('./src/components/Annoucements.vue')['default']
AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default'] AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default']
Apps: typeof import('./src/components/Apps.vue')['default'] Apps: typeof import('./src/components/Sidebar/Apps.vue')['default']
AppSidebar: typeof import('./src/components/AppSidebar.vue')['default'] AppSidebar: typeof import('./src/components/Sidebar/AppSidebar.vue')['default']
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default'] AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default'] AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
Assessments: typeof import('./src/components/Assessments.vue')['default'] Assessments: typeof import('./src/components/Assessments.vue')['default']
@@ -44,6 +44,7 @@ declare module 'vue' {
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default'] ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
CommandPalette: typeof import('./src/components/CommandPalette/CommandPalette.vue')['default'] CommandPalette: typeof import('./src/components/CommandPalette/CommandPalette.vue')['default']
CommandPaletteGroup: typeof import('./src/components/CommandPalette/CommandPaletteGroup.vue')['default'] CommandPaletteGroup: typeof import('./src/components/CommandPalette/CommandPaletteGroup.vue')['default']
Configuration: typeof import('./src/components/Sidebar/Configuration.vue')['default']
ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default'] ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default']
CouponDetails: typeof import('./src/components/Settings/Coupons/CouponDetails.vue')['default'] CouponDetails: typeof import('./src/components/Settings/Coupons/CouponDetails.vue')['default']
CouponItems: typeof import('./src/components/Settings/Coupons/CouponItems.vue')['default'] CouponItems: typeof import('./src/components/Settings/Coupons/CouponItems.vue')['default']
@@ -113,7 +114,7 @@ declare module 'vue' {
SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default'] SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default']
SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default'] SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default']
Settings: typeof import('./src/components/Settings/Settings.vue')['default'] Settings: typeof import('./src/components/Settings/Settings.vue')['default']
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default'] SidebarLink: typeof import('./src/components/Sidebar/SidebarLink.vue')['default']
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default'] StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default'] StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
Tags: typeof import('./src/components/Tags.vue')['default'] Tags: typeof import('./src/components/Tags.vue')['default']
@@ -125,7 +126,7 @@ declare module 'vue' {
Uploader: typeof import('./src/components/Controls/Uploader.vue')['default'] Uploader: typeof import('./src/components/Controls/Uploader.vue')['default']
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default'] UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default'] UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default'] UserDropdown: typeof import('./src/components/Sidebar/UserDropdown.vue')['default']
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default'] VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default'] VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default']
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default'] ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
+50 -46
View File
@@ -6,56 +6,60 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"serve": "vite preview", "serve": "vite preview",
"build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry", "build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry && yarn copy-colors-json",
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html" "copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html",
"copy-colors-json": "cp node_modules/frappe-ui/src/tailwind/colors.json src/utils/frappe-ui-colors.json"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "6.4.9",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "6.2.4",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "6.0.1",
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "6.2.1",
"@editorjs/checklist": "^1.6.0", "@editorjs/checklist": "1.6.0",
"@editorjs/code": "^2.9.0", "@editorjs/code": "2.9.0",
"@editorjs/editorjs": "^2.29.0", "@editorjs/editorjs": "2.29.0",
"@editorjs/embed": "^2.7.0", "@editorjs/embed": "2.7.0",
"@editorjs/header": "^2.8.1", "@editorjs/header": "2.8.1",
"@editorjs/inline-code": "^1.5.0", "@editorjs/inline-code": "1.5.0",
"@editorjs/nested-list": "^1.4.2", "@editorjs/nested-list": "1.4.2",
"@editorjs/paragraph": "^2.11.3", "@editorjs/paragraph": "2.11.3",
"@editorjs/simple-image": "^1.6.0", "@editorjs/simple-image": "1.6.0",
"@editorjs/table": "^2.4.2", "@editorjs/table": "2.4.2",
"@vueuse/core": "^10.4.1", "@vueuse/core": "10.4.1",
"@vueuse/router": "^12.7.0", "@vueuse/router": "12.7.0",
"ace-builds": "^1.36.2", "ace-builds": "1.36.2",
"apexcharts": "^4.3.0", "apexcharts": "4.3.0",
"chart.js": "^4.4.1", "chart.js": "4.4.1",
"codemirror": "^6.0.1", "codemirror": "6.0.1",
"dayjs": "^1.11.6", "dayjs": "1.11.10",
"dompurify": "^3.2.6", "dompurify": "3.2.6",
"feather-icons": "^4.28.0", "feather-icons": "4.28.0",
"frappe-ui": "^0.1.214", "frappe-ui": "0.1.227",
"highlight.js": "^11.11.1", "highlight.js": "11.11.1",
"lucide-vue-next": "^0.383.0", "lucide-vue-next": "0.383.0",
"markdown-it": "^14.0.0", "markdown-it": "14.0.0",
"pinia": "^2.0.33", "pinia": "2.0.33",
"plyr": "^3.7.8", "plyr": "3.7.8",
"socket.io-client": "^4.7.2", "socket.io-client": "4.7.2",
"tailwindcss": "3.4.15", "thememirror": "2.0.1",
"thememirror": "^2.0.1", "typescript": "5.7.2",
"typescript": "^5.7.2", "vue": "^3.5.0",
"vue": "^3.4.23", "vue-chartjs": "5.3.0",
"vue-chartjs": "^5.3.0", "vue-codemirror": "6.1.1",
"vue-codemirror": "^6.1.1", "vue-draggable-next": "2.2.1",
"vue-draggable-next": "^2.2.1", "vue-router": "4.2.2",
"vue-router": "^4.0.12", "vue3-apexcharts": "1.8.0",
"vue3-apexcharts": "^1.8.0",
"vuedraggable": "4.1.0" "vuedraggable": "4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.3", "@vitejs/plugin-vue": "5.0.3",
"autoprefixer": "^10.4.2", "autoprefixer": "10.4.2",
"postcss": "^8.4.5", "postcss": "8.4.5",
"vite": "^5.0.11", "vite": "5.0.11",
"vite-plugin-pwa": "^1.0.2" "tailwindcss": "^3.4.15",
"vite-plugin-pwa": "0.15.0"
},
"resolutions": {
"@iconify/utils": "2.1.7"
} }
} }
+3
View File
@@ -179,6 +179,9 @@
" "
:editable="true" :editable="true"
:fixedMenu="true" :fixedMenu="true"
:uploadArgs="{
private: true,
}"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]" editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/> />
</div> </div>
+13 -1
View File
@@ -113,7 +113,7 @@
{{ __('Enroll Now') }} {{ __('Enroll Now') }}
</Button> </Button>
<router-link <router-link
v-if="isModerator" v-if="canEditBatch"
:to="{ :to="{
name: 'BatchForm', name: 'BatchForm',
params: { params: {
@@ -209,7 +209,19 @@ const isEvaluator = computed(() => {
return user.data?.is_evaluator return user.data?.is_evaluator
}) })
const isInstructor = computed(() => {
return (
props.batch.data?.instructors?.filter(
(instructor) => instructor.name === user.data?.name
).length > 0
)
})
const canAccessBatch = computed(() => { const canAccessBatch = computed(() => {
return isModerator.value || isStudent.value || isEvaluator.value return isModerator.value || isStudent.value || isEvaluator.value
}) })
const canEditBatch = computed(() => {
return isModerator.value || isInstructor.value
})
</script> </script>
@@ -21,8 +21,10 @@
:style=" :style="
modelValue modelValue
? { ? {
backgroundColor: backgroundColor: getColor(
theme.backgroundColor[modelValue.toLowerCase()][400], modelValue.toLowerCase(),
400
),
} }
: {} : {}
" "
@@ -55,8 +57,7 @@
:key="color" :key="color"
class="size-5 rounded-full cursor-pointer" class="size-5 rounded-full cursor-pointer"
:style="{ :style="{
backgroundColor: backgroundColor: getColor(color.toLowerCase(), 400),
theme.backgroundColor[color.toLowerCase()][400],
}" }"
@click=" @click="
(e) => { (e) => {
@@ -79,7 +80,7 @@
import { Button, FormControl, Popover } from 'frappe-ui' import { Button, FormControl, Popover } from 'frappe-ui'
import { computed } from 'vue' import { computed } from 'vue'
import { Palette, X } from 'lucide-vue-next' import { Palette, X } from 'lucide-vue-next'
import { theme } from '@/utils/theme' import { getColor } from '@/utils'
const emit = defineEmits(['update:modelValue', 'change']) const emit = defineEmits(['update:modelValue', 'change'])
@@ -20,7 +20,7 @@
class="w-4 h-4 text-ink-gray-7 stroke-1.5" class="w-4 h-4 text-ink-gray-7 stroke-1.5"
:is="icons.Folder" :is="icons.Folder"
/> />
<span v-if="selectedIcon"> <span v-if="selectedIcon" class="text-ink-gray-7">
{{ selectedIcon }} {{ selectedIcon }}
</span> </span>
<span v-else class="text-ink-gray-5"> <span v-else class="text-ink-gray-5">
@@ -28,10 +28,12 @@
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2" class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
> >
<ComboboxOptions <ComboboxOptions
class="my-1 min-h-[6rem] max-h-[12rem] overflow-y-auto px-1.5" class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
static static
> >
<ComboboxOption <ComboboxOption
v-if="options.length"
v-for="option in options" v-for="option in options"
:key="option.value" :key="option.value"
:value="option" :value="option"
@@ -53,7 +55,9 @@
</div> </div>
</li> </li>
</ComboboxOption> </ComboboxOption>
<div class="h-10"></div> <div v-else class="text-ink-gray-7 px-4">
{{ __('No results found') }}
</div>
<div <div
v-if="attrs.onCreate" v-if="attrs.onCreate"
class="absolute bottom-2 left-1 w-[95%] pt-2 bg-white border-t" class="absolute bottom-2 left-1 w-[95%] pt-2 bg-white border-t"
+4 -2
View File
@@ -136,11 +136,11 @@
import { Award, BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next' import { Award, BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { Tooltip } from 'frappe-ui' import { Tooltip } from 'frappe-ui'
import { theme } from '@/utils/theme'
import { formatAmount } from '@/utils' import { formatAmount } from '@/utils'
import CourseInstructors from '@/components/CourseInstructors.vue' import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import ProgressBar from '@/components/ProgressBar.vue' import ProgressBar from '@/components/ProgressBar.vue'
import colors from '@/utils/frappe-ui-colors.json'
const { user } = sessionStore() const { user } = sessionStore()
@@ -152,8 +152,10 @@ const props = defineProps({
}) })
const getGradientColor = () => { const getGradientColor = () => {
let theme =
localStorage.getItem('theme') == 'light' ? 'lightMode' : 'darkMode'
let color = props.course.card_gradient?.toLowerCase() || 'blue' let color = props.course.card_gradient?.toLowerCase() || 'blue'
let colorMap = theme.backgroundColor[color] let colorMap = colors[theme][color]
return `linear-gradient(to top right, black, ${colorMap[400]})` return `linear-gradient(to top right, black, ${colorMap[400]})`
/* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */ /* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */
/* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */ /* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */
+1 -1
View File
@@ -9,5 +9,5 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import AppSidebar from './AppSidebar.vue' import AppSidebar from '@/components/Sidebar/AppSidebar.vue'
</script> </script>
@@ -114,11 +114,11 @@
categoryColumn: 'category', categoryColumn: 'category',
valueColumn: 'count', valueColumn: 'count',
colors: [ colors: [
theme.colors.red['400'], getColor('red', 400),
theme.colors.amber['400'], getColor('amber', 400),
theme.colors.pink['400'], getColor('pink', 400),
theme.colors.blue['400'], getColor('blue', 400),
theme.colors.green['400'], getColor('green', 400),
], ],
}" }"
/> />
@@ -146,7 +146,7 @@ import {
NumberChart, NumberChart,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { theme } from '@/utils/theme' import { getColor } from '@/utils'
const show = defineModel<boolean>({ default: false }) const show = defineModel<boolean>({ default: false })
const searchFilter = ref<string | null>(null) const searchFilter = ref<string | null>(null)
@@ -222,8 +222,8 @@ watch(
watch( watch(
() => profile.language, () => profile.language,
(newVal, oldVal) => { () => {
if (newVal !== oldVal) { if (profile.language !== props.profile.data.language) {
hasLanguageChanged.value = true hasLanguageChanged.value = true
} }
} }
@@ -66,12 +66,18 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, createResource, Select, FormControl, toast } from 'frappe-ui' import {
dayjs,
Dialog,
createResource,
Select,
FormControl,
toast,
} from 'frappe-ui'
import { reactive, watch, inject } from 'vue' import { reactive, watch, inject } from 'vue'
import { formatTime } from '@/utils/' import { formatTime } from '@/utils/'
const user = inject('$user') const user = inject('$user')
const dayjs = inject('$dayjs')
const show = defineModel() const show = defineModel()
const evaluations = defineModel('reloadEvals') const evaluations = defineModel('reloadEvals')
+2 -1
View File
@@ -1,7 +1,6 @@
<template> <template>
<Dialog <Dialog
v-model="show" v-model="show"
class="text-base"
:options="{ :options="{
title: __('Add web page to sidebar'), title: __('Add web page to sidebar'),
size: 'lg', size: 'lg',
@@ -17,6 +16,7 @@
}" }"
> >
<template #body-content> <template #body-content>
<div class="text-base">
<Link <Link
v-model="page.webpage" v-model="page.webpage"
doctype="Web Page" doctype="Web Page"
@@ -26,6 +26,7 @@
}" }"
/> />
<IconPicker v-model="page.icon" :label="__('Icon')" class="mt-4" /> <IconPicker v-model="page.icon" :label="__('Icon')" class="mt-4" />
</div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
@@ -117,9 +117,16 @@ const props = defineProps({
watch( watch(
() => props.accountID, () => props.accountID,
(val) => { (val) => {
if (val != 'new') { if (val === 'new') {
zoomAccounts.value?.data.forEach((acc) => { account.name = ''
if (acc.name === val) { account.enabled = false
account.member = user?.data?.name || ''
account.account_id = ''
account.client_id = ''
account.client_secret = ''
} else if (val && val !== 'new') {
const acc = zoomAccounts.value?.data.find((acc) => acc.name === val)
if (acc) {
account.name = acc.name account.name = acc.name
account.enabled = acc.enabled || false account.enabled = acc.enabled || false
account.member = acc.member account.member = acc.member
@@ -127,22 +134,10 @@ watch(
account.client_id = acc.client_id account.client_id = acc.client_id
account.client_secret = acc.client_secret account.client_secret = acc.client_secret
} }
})
} }
} }
) )
watch(show, (val) => {
if (!val) {
account.name = ''
account.enabled = false
account.member = user?.data?.name || ''
account.account_id = ''
account.client_id = ''
account.client_secret = ''
}
})
const saveAccount = (close: () => void) => { const saveAccount = (close: () => void) => {
if (props.accountID == 'new') { if (props.accountID == 'new') {
createAccount(close) createAccount(close)
@@ -20,7 +20,7 @@
<span <span
class="size-3 rounded-full" class="size-3 rounded-full"
:style="{ :style="{
backgroundColor: theme.backgroundColor[color.toLowerCase()][400], backgroundColor: getColor(color.toLowerCase(), 400),
}" }"
></span> ></span>
<span> <span>
@@ -55,9 +55,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, ref, watch } from 'vue' import { computed, inject, ref, watch } from 'vue'
import { NotepadText, Trash2 } from 'lucide-vue-next' import { NotepadText, Trash2 } from 'lucide-vue-next'
import { theme } from '@/utils/theme'
import type { Note, Notes } from '@/components/Notes/types' import type { Note, Notes } from '@/components/Notes/types'
import { blockQuotesClick, highlightText } from '@/utils' import { blockQuotesClick, getColor, highlightText } from '@/utils'
const user = inject<any>('$user') const user = inject<any>('$user')
const show = defineModel() const show = defineModel()
+3
View File
@@ -7,6 +7,9 @@
:placeholder="__('Make notes for quick revision. Press / for menu.')" :placeholder="__('Make notes for quick revision. Press / for menu.')"
@change="(val: string) => updateNoteText(val)" @change="(val: string) => updateNoteText(val)"
:editable="true" :editable="true"
:uploadArgs="{
private: true,
}"
editorClass="prose prose-sm min-h-[200px] max-w-none" editorClass="prose prose-sm min-h-[200px] max-w-none"
/> />
</template> </template>
+49 -49
View File
@@ -29,7 +29,7 @@
<div <div
v-if="activeTab && data.doc" v-if="activeTab && data.doc"
:key="activeTab.label" :key="activeTab.label"
class="flex flex-1 flex-col p-8 bg-surface-modal" class="flex flex-1 flex-col p-8 bg-surface-modal overflow-x-auto"
> >
<component <component
v-if="activeTab.template" v-if="activeTab.template"
@@ -71,7 +71,7 @@ import { Dialog, createDocumentResource } from 'frappe-ui'
import { computed, markRaw, ref, watch } from 'vue' import { computed, markRaw, ref, watch } from 'vue'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
import SettingDetails from '@/components/Settings/SettingDetails.vue' import SettingDetails from '@/components/Settings/SettingDetails.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/Sidebar/SidebarLink.vue'
import Members from '@/components/Settings/Members.vue' import Members from '@/components/Settings/Members.vue'
import Evaluators from '@/components/Settings/Evaluators.vue' import Evaluators from '@/components/Settings/Evaluators.vue'
import Categories from '@/components/Settings/Categories.vue' import Categories from '@/components/Settings/Categories.vue'
@@ -181,6 +181,53 @@ const tabsStructure = computed(() => {
}, },
], ],
}, },
{
label: 'Lists',
hideLabel: false,
items: [
{
label: 'Members',
description:
'Add new members or manage roles and permissions of existing members',
icon: 'UserRoundPlus',
template: markRaw(Members),
},
{
label: 'Evaluators',
description: '',
icon: 'UserCheck',
description:
'Add new evaluators or check the slots existing evaluators',
template: markRaw(Evaluators),
},
{
label: 'Zoom Accounts',
description:
'Manage zoom accounts to conduct live classes from batches',
icon: 'Video',
template: markRaw(ZoomSettings),
},
{
label: 'Badges',
description:
'Create badges and assign them to students to acknowledge their achievements',
icon: 'Award',
template: markRaw(Badges),
},
{
label: 'Categories',
description: 'Double click to edit the category',
icon: 'Network',
template: markRaw(Categories),
},
{
label: 'Email Templates',
description: 'Manage the email templates for your learning system',
icon: 'MailPlus',
template: markRaw(EmailTemplates),
},
],
},
{ {
label: 'Payment', label: 'Payment',
hideLabel: false, hideLabel: false,
@@ -242,53 +289,6 @@ const tabsStructure = computed(() => {
}, },
], ],
}, },
{
label: 'Lists',
hideLabel: false,
items: [
{
label: 'Members',
description:
'Add new members or manage roles and permissions of existing members',
icon: 'UserRoundPlus',
template: markRaw(Members),
},
{
label: 'Evaluators',
description: '',
icon: 'UserCheck',
description:
'Add new evaluators or check the slots existing evaluators',
template: markRaw(Evaluators),
},
{
label: 'Zoom Accounts',
description:
'Manage zoom accounts to conduct live classes from batches',
icon: 'Video',
template: markRaw(ZoomSettings),
},
{
label: 'Badges',
description:
'Create badges and assign them to students to acknowledge their achievements',
icon: 'Award',
template: markRaw(Badges),
},
{
label: 'Categories',
description: 'Double click to edit the category',
icon: 'Network',
template: markRaw(Categories),
},
{
label: 'Email Templates',
description: 'Manage the email templates for your learning system',
icon: 'MailPlus',
template: markRaw(EmailTemplates),
},
],
},
{ {
label: 'Customize', label: 'Customize',
hideLabel: false, hideLabel: false,
@@ -187,11 +187,12 @@ import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar' import { useSidebar } from '@/stores/sidebar'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
import { Button, call, createResource, Tooltip } from 'frappe-ui' import { Button, call, createResource, Tooltip, toast } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue' import PageModal from '@/components/Modals/PageModal.vue'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import LMSLogo from '@/components/Icons/LMSLogo.vue' import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import InviteIcon from '@/components/Icons/InviteIcon.vue'
import { import {
ref, ref,
onMounted, onMounted,
@@ -456,21 +457,13 @@ const openPageModal = (link) => {
} }
const deletePage = (link) => { const deletePage = (link) => {
createResource({ call('lms.lms.api.delete_documents', {
url: 'lms.lms.api.delete_sidebar_item', doctype: 'LMS Sidebar Item',
makeParams(values) { documents: [link.name],
return { }).then(() => {
webpage: link.web_page,
}
},
}).submit(
{},
{
onSuccess() {
sidebarSettings.reload() sidebarSettings.reload()
}, toast.success(__('Page deleted successfully'))
} })
)
} }
const toggleSidebar = () => { const toggleSidebar = () => {
@@ -1,11 +1,10 @@
<template> <template>
<Popover placement="right-start" class="flex w-full"> <Popover placement="right-start" trigger="hover" class="flex w-full">
<template #target="{ togglePopover }"> <template #target="{ togglePopover }">
<button <button
:class="[ :class="[
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-7 hover:bg-surface-gray-2', 'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-7 hover:bg-surface-gray-2',
]" ]"
@click.prevent="togglePopover()"
> >
<div class="flex gap-2"> <div class="flex gap-2">
<LayoutGrid class="size-4 stroke-1.5" /> <LayoutGrid class="size-4 stroke-1.5" />
@@ -0,0 +1,26 @@
<template>
<div class="grid grid-cols-3 justify-between bg-surface-white">
<div key="name" class="py-1 px-2 hover:bg-surface-gray-2 rounded">
<router-link
:to="{
name: 'DataImportList',
query: {
step: 'list',
},
}"
>
<div class="flex flex-col items-center space-y-1">
<ArrowDownToLine
class="size-9 text-ink-gray-7 p-2 bg-surface-gray-2 rounded-md"
/>
<div class="text-sm text-ink-gray-7">
{{ __('Import') }}
</div>
</div>
</router-link>
</div>
</div>
</template>
<script setup lang="ts">
import { ArrowDownToLine } from 'lucide-vue-next'
</script>
@@ -1,7 +1,7 @@
<template> <template>
<div class="p-2"> <div class="p-2">
<Dropdown :options="userDropdownOptions"> <Dropdown :options="userDropdownOptions">
<template v-slot="{ open }"> <template v-slot="{ open, close }">
<button <button
class="flex h-12 py-2 items-center rounded-md duration-300 ease-in-out" class="flex h-12 py-2 items-center rounded-md duration-300 ease-in-out"
:class=" :class="
@@ -64,18 +64,19 @@
</template> </template>
<script setup> <script setup>
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { Dropdown } from 'frappe-ui' import { Dropdown } from 'frappe-ui'
import Apps from '@/components/Apps.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { convertToTitleCase } from '@/utils' import { convertToTitleCase } from '@/utils'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
import { markRaw, watch, ref, onMounted, computed } from 'vue' import { markRaw, watch, ref, onMounted, computed } from 'vue'
import { createDialog } from '@/utils/dialogs' import { createDialog } from '@/utils/dialogs'
import SettingsModal from '@/components/Settings/Settings.vue' import Apps from '@/components/Sidebar/Apps.vue'
import Configuration from '@/components/Sidebar/Configuration.vue'
import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue' import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue'
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import SettingsModal from '@/components/Settings/Settings.vue'
import { import {
ChevronDown, ChevronDown,
LogIn, LogIn,
@@ -84,6 +85,7 @@ import {
User, User,
Settings, Settings,
Sun, Sun,
Wrench,
Zap, Zap,
} from 'lucide-vue-next' } from 'lucide-vue-next'
@@ -168,6 +170,18 @@ const userDropdownOptions = computed(() => {
return userResource.data?.is_moderator return userResource.data?.is_moderator
}, },
}, },
{
label: 'Configuration',
icon: Wrench,
submenu: [
{
component: markRaw(Configuration),
},
],
condition: () => {
return userResource.data?.is_moderator
},
},
{ {
icon: FrappeCloudIcon, icon: FrappeCloudIcon,
label: 'Login to Frappe Cloud', label: 'Login to Frappe Cloud',
+6 -6
View File
@@ -17,7 +17,7 @@
import { createResource } from 'frappe-ui' import { createResource } from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue' import { computed, inject, onMounted, ref, watch } from 'vue'
import ApexChart from 'vue3-apexcharts' import ApexChart from 'vue3-apexcharts'
import { theme } from '@/utils/theme' import { getColor } from '@/utils'
const user = inject('$user') const user = inject('$user')
const labels = ref([]) const labels = ref([])
@@ -81,11 +81,11 @@ const chartOptions = computed(() => {
enableShades: true, enableShades: true,
colorScale: { colorScale: {
ranges: [ ranges: [
{ from: 0, to: 0, color: theme.colors.gray[400] }, { from: 0, to: 0, color: getColor('green', 400) },
{ from: 1, to: 5, color: theme.colors.green[200] }, { from: 1, to: 5, color: getColor('green', 200) },
{ from: 6, to: 15, color: theme.colors.green[500] }, { from: 6, to: 15, color: getColor('green', 500) },
{ from: 16, to: 30, color: theme.colors.green[700] }, { from: 16, to: 30, color: getColor('green', 700) },
{ from: 31, to: 100, color: theme.colors.green[800] }, { from: 31, to: 100, color: getColor('green', 800) },
], ],
}, },
}, },
+1 -1
View File
@@ -1,3 +1,3 @@
@import './assets/Inter/inter.css'; @import './assets/Inter/inter.css';
@import 'frappe-ui/src/style.css'; @import 'frappe-ui/style.css';
@import './styles/codemirror.css'; @import './styles/codemirror.css';
+48 -3
View File
@@ -3,7 +3,49 @@
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5" class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<router-link <Dropdown
v-if="canCreateBatch()"
:options="[
{
label: __('New Batch'),
icon: 'users',
onClick() {
router.push({
name: 'BatchForm',
params: { batchName: 'new' },
})
},
},
{
label: __('Import Batch'),
icon: 'upload',
onClick() {
router.push({
name: 'NewDataImport',
params: { doctype: 'LMS Batch' },
})
},
},
]"
>
<template v-slot="{ open }">
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('Create') }}
<template #suffix>
<ChevronDown
:class="[
'w-4 h-4 stroke-1.5 ml-1 transform transition-transform',
open ? 'rotate-180' : '',
]"
/>
</template>
</Button>
</template>
</Dropdown>
<!-- <router-link
v-if="canCreateBatch()" v-if="canCreateBatch()"
:to="{ :to="{
name: 'BatchForm', name: 'BatchForm',
@@ -16,7 +58,7 @@
</template> </template>
{{ __('Create') }} {{ __('Create') }}
</Button> </Button>
</router-link> </router-link> -->
</header> </header>
<div class="p-5 pb-10"> <div class="p-5 pb-10">
<div <div
@@ -90,13 +132,15 @@ import {
Button, Button,
call, call,
createListResource, createListResource,
Dropdown,
FormControl, FormControl,
Select, Select,
TabButtons, TabButtons,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue' import { computed, inject, onMounted, ref, watch } from 'vue'
import { Plus } from 'lucide-vue-next' import { useRouter } from 'vue-router'
import { ChevronDown, Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import BatchCard from '@/components/BatchCard.vue' import BatchCard from '@/components/BatchCard.vue'
import EmptyState from '@/components/EmptyState.vue' import EmptyState from '@/components/EmptyState.vue'
@@ -115,6 +159,7 @@ const is_student = computed(() => user.data?.is_student)
const currentTab = ref(is_student.value ? 'All' : 'Upcoming') const currentTab = ref(is_student.value ? 'All' : 'Upcoming')
const orderBy = ref('start_date') const orderBy = ref('start_date')
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
const router = useRouter()
onMounted(() => { onMounted(() => {
setFiltersFromQuery() setFiltersFromQuery()
+37 -5
View File
@@ -3,20 +3,51 @@
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5" class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<router-link
<Dropdown
placement="start"
side="bottom"
v-if="canCreateCourse()" v-if="canCreateCourse()"
:to="{ :options="[
{
label: __('New Course'),
icon: 'book-open',
onClick() {
router.push({
name: 'CourseForm', name: 'CourseForm',
params: { courseName: 'new' }, params: { courseName: 'new' },
}" })
},
},
{
label: __('Import Course'),
icon: 'upload',
onClick() {
router.push({
name: 'NewDataImport',
params: { doctype: 'LMS Course' },
})
},
},
]"
> >
<template v-slot="{ open }">
<Button variant="solid"> <Button variant="solid">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4 stroke-1.5" /> <Plus class="h-4 w-4 stroke-1.5" />
</template> </template>
{{ __('Create') }} {{ __('Create') }}
<template #suffix>
<ChevronDown
:class="[
'w-4 h-4 stroke-1.5 ml-1 transform transition-transform',
open ? 'rotate-180' : '',
]"
/>
</template>
</Button> </Button>
</router-link> </template>
</Dropdown>
</header> </header>
<div class="p-5 pb-10"> <div class="p-5 pb-10">
<div <div
@@ -85,13 +116,14 @@ import {
Button, Button,
call, call,
createListResource, createListResource,
Dropdown,
FormControl, FormControl,
Select, Select,
TabButtons, TabButtons,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue' import { computed, inject, onMounted, ref, watch } from 'vue'
import { Plus } from 'lucide-vue-next' import { ChevronDown, Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { canCreateCourse } from '@/utils' import { canCreateCourse } from '@/utils'
import CourseCard from '@/components/CourseCard.vue' import CourseCard from '@/components/CourseCard.vue'
+50
View File
@@ -0,0 +1,50 @@
<template>
<DataImport
:doctype="route.params.doctype"
:importName="route.params.importName"
:doctypeMap="doctypeMap"
/>
</template>
<script setup lang="ts">
import { usePageMeta } from 'frappe-ui'
import { DataImport } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session'
import { useRoute, useRouter } from 'vue-router'
import { inject, onMounted } from 'vue'
const { brand } = sessionStore()
const route = useRoute()
const router = useRouter()
const user = inject<any>('$user')
onMounted(() => {
if (!user.data?.is_moderator) {
router.push({
name: 'Courses',
})
}
})
const doctypeMap = {
'LMS Course': {
title: 'Courses',
listRoute: '/courses',
pageRoute: `/courses/docname`,
},
'LMS Batch': {
title: 'Batches',
listRoute: '/batches',
},
'LMS Category': {
title: 'Categories',
listRoute: '/lms',
},
}
usePageMeta(() => {
return {
title: __('Data Import'),
icon: brand.favicon,
}
})
</script>
+3 -3
View File
@@ -30,7 +30,7 @@
<div v-if="createdBatches.data?.length" class="mt-10"> <div v-if="createdBatches.data?.length" class="mt-10">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<span class="font-semibold text-lg"> <span class="font-semibold text-lg text-ink-gray-9">
{{ __('Upcoming Batches') }} {{ __('Upcoming Batches') }}
</span> </span>
<router-link <router-link
@@ -88,7 +88,7 @@
<div class="grid grid-cols-2 gap-5 mt-10"> <div class="grid grid-cols-2 gap-5 mt-10">
<div v-if="evals?.data?.length"> <div v-if="evals?.data?.length">
<div class="font-semibold text-lg mb-3"> <div class="font-semibold text-lg text-ink-gray-9 mb-3">
{{ __('Upcoming Evaluations') }} {{ __('Upcoming Evaluations') }}
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
@@ -124,7 +124,7 @@
</div> </div>
</div> </div>
<div v-if="liveClasses?.data?.length"> <div v-if="liveClasses?.data?.length">
<div class="font-semibold text-lg mb-3"> <div class="font-semibold text-lg text-ink-gray-9 mb-3">
{{ __('Upcoming Live Classes') }} {{ __('Upcoming Live Classes') }}
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
+30 -11
View File
@@ -17,18 +17,14 @@
</header> </header>
<div class="max-w-4xl mx-auto pt-5 p-4"> <div class="max-w-4xl mx-auto pt-5 p-4">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-lg font-semibold text-ink-gray-9 mb-2"> <h1 class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
{{ applications.data?.length || 0 }} {{ applicationCount }}
{{ {{ applicationCount === 1 ? __('Application') : __('Applications') }}
applications.data?.length === 1
? __('Application')
: __('Applications')
}}
</h1> </h1>
</div> </div>
<div v-if="applications.data?.length">
<ListView <ListView
v-if="applications.data?.length"
:columns="applicationColumns" :columns="applicationColumns"
:rows="applicantRows" :rows="applicantRows"
row-key="name" row-key="name"
@@ -97,6 +93,15 @@
</ListRow> </ListRow>
</ListRows> </ListRows>
</ListView> </ListView>
<div class="flex justify-center mt-5">
<Button v-if="applications.hasNextPage" @click="applications.next()">
<template #prefix>
<RefreshCw class="size-4 stroke-1.5" />
</template>
{{ __('Load More') }}
</Button>
</div>
</div>
<EmptyState v-else-if="!applications.loading" type="Job Applications" /> <EmptyState v-else-if="!applications.loading" type="Job Applications" />
</div> </div>
@@ -150,6 +155,7 @@ import {
Avatar, Avatar,
Button, Button,
Breadcrumbs, Breadcrumbs,
call,
Dialog, Dialog,
Dropdown, Dropdown,
FeatherIcon, FeatherIcon,
@@ -166,8 +172,8 @@ import {
usePageMeta, usePageMeta,
toast, toast,
} from 'frappe-ui' } from 'frappe-ui'
import { RefreshCw } from 'lucide-vue-next'
import { inject, ref, computed, reactive } from 'vue' import { computed, inject, onMounted, ref, reactive } from 'vue'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import EmptyState from '@/components/EmptyState.vue' import EmptyState from '@/components/EmptyState.vue'
@@ -175,6 +181,7 @@ const dayjs = inject('$dayjs')
const { brand } = sessionStore() const { brand } = sessionStore()
const showEmailModal = ref(false) const showEmailModal = ref(false)
const selectedApplicant = ref(null) const selectedApplicant = ref(null)
const applicationCount = ref(0)
const emailForm = reactive({ const emailForm = reactive({
subject: '', subject: '',
message: '', message: '',
@@ -188,6 +195,19 @@ const props = defineProps({
}, },
}) })
onMounted(() => {
getApplicationCount()
})
const getApplicationCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Job Application',
filters: { job: props.job },
}).then((count) => {
applicationCount.value = count
})
}
const applications = createListResource({ const applications = createListResource({
doctype: 'LMS Job Application', doctype: 'LMS Job Application',
fields: [ fields: [
@@ -253,7 +273,6 @@ const sendEmail = (close) => {
} }
const downloadResume = (resumeUrl) => { const downloadResume = (resumeUrl) => {
console.log(resumeUrl)
window.open(resumeUrl, '_blank') window.open(resumeUrl, '_blank')
} }
+2 -4
View File
@@ -32,7 +32,7 @@
</Button> </Button>
</router-link> </router-link>
<router-link <router-link
v-if="user.data.name == job.data?.owner" v-if="canManageJob"
:to="{ :to="{
name: 'JobForm', name: 'JobForm',
params: { jobName: job.data?.name }, params: { jobName: job.data?.name },
@@ -240,9 +240,7 @@ const redirectToWebsite = (url) => {
const canManageJob = computed(() => { const canManageJob = computed(() => {
if (!user.data?.name || !job.data) return false if (!user.data?.name || !job.data) return false
return ( return user.data.name === job.data.owner || user.data?.is_moderator
user.data.name === job.data.owner || user.data.roles?.includes('Moderator')
)
}) })
usePageMeta(() => { usePageMeta(() => {
+10 -1
View File
@@ -207,6 +207,11 @@ const jobDetail = createResource({
} }
}, },
onSuccess(data) { onSuccess(data) {
if (data.owner != user.data?.name && !user.data?.is_moderator) {
router.push({
name: 'Jobs',
})
}
Object.keys(data).forEach((key) => { Object.keys(data).forEach((key) => {
if (Object.hasOwn(job, key)) job[key] = data[key] if (Object.hasOwn(job, key)) job[key] = data[key]
}) })
@@ -242,7 +247,11 @@ const job = reactive({
}) })
onMounted(() => { onMounted(() => {
if (!user.data) window.location.href = '/login' if (!user.data) {
router.push({
name: 'Jobs',
})
}
if (props.jobName != 'new') jobDetail.reload() if (props.jobName != 'new') jobDetail.reload()
}) })
+72 -11
View File
@@ -32,10 +32,13 @@
{{ __('{0} Open Jobs').format(jobCount) }} {{ __('{0} Open Jobs').format(jobCount) }}
</div> </div>
<div <div class="flex items-center justify-between space-x-4">
class="grid grid-cols-1 gap-2 md:grid-cols-4" <TabButtons
:class="user.data ? 'md:grid-cols-3' : 'md:grid-cols-2'" v-if="tabs.length > 1"
> v-model="activeTab"
:buttons="tabs"
@change="updateJobs"
/>
<FormControl <FormControl
type="text" type="text"
:placeholder="__('Search')" :placeholder="__('Search')"
@@ -55,13 +58,13 @@
doctype="Country" doctype="Country"
v-model="country" v-model="country"
:placeholder="__('Country')" :placeholder="__('Country')"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40" class="min-w-32 lg:min-w-0 lg:w-32 xl:w-32"
/> />
<FormControl <FormControl
v-model="jobType" v-model="jobType"
type="select" type="select"
:options="jobTypes" :options="jobTypes"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40" class="min-w-32 lg:min-w-0 lg:w-32 xl:w-32"
:placeholder="__('Type')" :placeholder="__('Type')"
@change="updateJobs" @change="updateJobs"
/> />
@@ -69,7 +72,7 @@
v-model="workMode" v-model="workMode"
type="select" type="select"
:options="workModes" :options="workModes"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40" class="min-w-32 lg:min-w-0 lg:w-32 xl:w-32"
:placeholder="__('Work Mode')" :placeholder="__('Work Mode')"
@change="updateJobs" @change="updateJobs"
/> />
@@ -100,6 +103,7 @@ import {
call, call,
createResource, createResource,
FormControl, FormControl,
TabButtons,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { Plus, Search } from 'lucide-vue-next' import { Plus, Search } from 'lucide-vue-next'
@@ -118,9 +122,38 @@ const country = ref(null)
const filters = ref({}) const filters = ref({})
const orFilters = ref({}) const orFilters = ref({})
const jobCount = ref(0) const jobCount = ref(0)
const closedJobs = ref(0)
const activeTab = ref('Open')
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
onMounted(() => { onMounted(() => {
getClosedJobCount()
setFiltersFromURL()
updateJobs()
})
const isModerator = computed(() => {
return user.data?.is_moderator
})
const getClosedJobCount = () => {
const filters = {
status: 'Closed',
}
if (!isModerator.value) {
filters.owner = user.data?.name
}
call('frappe.client.get_count', {
doctype: 'Job Opportunity',
filters: filters,
}).then((count) => {
closedJobs.value = count
})
}
const setFiltersFromURL = () => {
let queries = new URLSearchParams(location.search) let queries = new URLSearchParams(location.search)
if (queries.has('type')) { if (queries.has('type')) {
jobType.value = queries.get('type') jobType.value = queries.get('type')
@@ -128,7 +161,22 @@ onMounted(() => {
if (queries.has('work_mode')) { if (queries.has('work_mode')) {
workMode.value = queries.get('work_mode') workMode.value = queries.get('work_mode')
} }
updateJobs() }
const tabs = computed(() => {
const tabsArray = [
{
label: __('Open'),
},
]
if (closedJobs.value) {
tabsArray.push({
label: __('Closed'),
})
}
return tabsArray
}) })
const jobs = createResource({ const jobs = createResource({
@@ -149,7 +197,6 @@ const updateJobs = () => {
const updateFilters = () => { const updateFilters = () => {
filters.value.status = 'Open' filters.value.status = 'Open'
filters.value.disabled = 0
if (jobType.value) { if (jobType.value) {
filters.value.type = jobType.value filters.value.type = jobType.value
@@ -178,8 +225,22 @@ const updateFilters = () => {
} else { } else {
delete filters.value.country delete filters.value.country
} }
if (activeTab.value === 'Closed') {
filters.value.status = 'Closed'
if (!isModerator.value) {
filters.value.owner = user.data?.name
}
} else {
filters.value.status = 'Open'
delete filters.value.owner
}
} }
watch(activeTab, (val) => {
updateJobs()
})
watch(country, (val) => { watch(country, (val) => {
updateJobs() updateJobs()
}) })
@@ -190,7 +251,7 @@ watch(jobs, () => {
const jobTypes = computed(() => { const jobTypes = computed(() => {
return [ return [
'', { label: '', value: '' },
{ label: __('Full Time'), value: 'Full Time' }, { label: __('Full Time'), value: 'Full Time' },
{ label: __('Part Time'), value: 'Part Time' }, { label: __('Part Time'), value: 'Part Time' },
{ label: __('Contract'), value: 'Contract' }, { label: __('Contract'), value: 'Contract' },
@@ -200,7 +261,7 @@ const jobTypes = computed(() => {
const workModes = computed(() => { const workModes = computed(() => {
return [ return [
'', { label: '', value: '' },
{ label: 'On site', value: 'On-site' }, { label: 'On site', value: 'On-site' },
{ label: 'Hybrid', value: 'Hybrid' }, { label: 'Hybrid', value: 'Hybrid' },
{ label: 'Remote', value: 'Remote' }, { label: 'Remote', value: 'Remote' },
+75 -53
View File
@@ -58,15 +58,15 @@
</Button> </Button>
</div> </div>
<ListView <ListView
v-if="programCourses.data.length > 0" v-if="program.program_courses?.length > 0"
:columns="courseColumns" :columns="courseColumns"
:rows="programCourses.data" :rows="program.program_courses"
:options="{ :options="{
selectable: true, selectable: true,
resizeColumn: true, resizeColumn: true,
showTooltip: false, showTooltip: false,
}" }"
rowKey="name" :rowKey="programName === 'new' ? 'course' : 'name'"
> >
<ListHeader <ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2" class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
@@ -75,8 +75,8 @@
</ListHeader> </ListHeader>
<ListRows> <ListRows>
<Draggable <Draggable
:list="programCourses.data" :list="program.program_courses"
item-key="name" :item-key="programName === 'new' ? 'course' : 'name'"
group="items" group="items"
@end="updateOrder" @end="updateOrder"
class="cursor-move" class="cursor-move"
@@ -133,14 +133,14 @@
</div> </div>
</div> </div>
<ListView <ListView
v-if="programMembers.data.length > 0" v-if="program.program_members?.length > 0"
:columns="memberColumns" :columns="memberColumns"
:rows="programMembers.data" :rows="program.program_members"
:options="{ :options="{
selectable: true, selectable: true,
resizeColumn: true, resizeColumn: true,
}" }"
rowKey="name" :rowKey="programName === 'new' ? 'member' : 'name'"
> >
<ListHeader <ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2" class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
@@ -148,7 +148,7 @@
<ListHeaderItem :item="item" v-for="item in memberColumns" /> <ListHeaderItem :item="item" v-for="item in memberColumns" />
</ListHeader> </ListHeader>
<ListRows> <ListRows>
<ListRow :row="row" v-for="row in programMembers.data" /> <ListRow :row="row" v-for="row in program.program_members" />
</ListRows> </ListRows>
<ListSelectBanner> <ListSelectBanner>
<template #actions="{ unselectAll, selections }"> <template #actions="{ unselectAll, selections }">
@@ -217,13 +217,12 @@
/> />
</template> </template>
<template #actions="{ close }"> <template #actions="{ close }">
<div class="flex justify-end space-x-2 group"> <div class="flex justify-end space-x-2">
<Button <Button
v-if="programName != 'new'" v-if="programName != 'new'"
@click="deleteProgram(close)" @click="deleteProgram(close)"
variant="outline" variant="outline"
theme="red" theme="red"
class="invisible group-hover:visible"
> >
<template #prefix> <template #prefix>
<Trash2 class="size-4 stroke-1.5" /> <Trash2 class="size-4 stroke-1.5" />
@@ -252,7 +251,7 @@ import {
ListRow, ListRow,
toast, toast,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch, getCurrentInstance } from 'vue'
import { Plus, Trash2, TrendingUp } from 'lucide-vue-next' import { Plus, Trash2, TrendingUp } from 'lucide-vue-next'
import { Programs, Program } from '@/types/programs' import { Programs, Program } from '@/types/programs'
import { escapeHTML, openSettings } from '@/utils' import { escapeHTML, openSettings } from '@/utils'
@@ -269,6 +268,9 @@ const member = ref<string>('')
const showProgressDialog = ref(false) const showProgressDialog = ref(false)
const dirty = ref(false) const dirty = ref(false)
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
programName: string | null programName: string | null
@@ -427,25 +429,22 @@ const addCourse = (close: () => void) => {
return return
} }
programCourses.insert.submit( const existingCourse = program.value.program_courses.find(
{ (c: any) => c.course === course.value
parent: props.programName, )
parenttype: 'LMS Program', if (!existingCourse) {
parentfield: 'program_courses', program.value.program_courses.push({
course: course.value, course: course.value,
idx: programCourses.data.length + 1, idx: program.value.program_courses.length + 1,
}, })
{ if (props.programName !== 'new') {
onSuccess() { dirty.value = true
updateCounts('course', 'add') }
close() close()
toast.success(__('Course added to program successfully')) toast.success(__('Course added to program successfully'))
}, } else {
onError(err: any) { toast.warning(__('Course already added to program'))
toast.warning(__(err.messages?.[0] || err))
},
} }
)
} }
const addMember = (close: () => void) => { const addMember = (close: () => void) => {
@@ -454,24 +453,21 @@ const addMember = (close: () => void) => {
return return
} }
programMembers.insert.submit( const existingMember = program.value.program_members.find(
{ (m) => m.member === member.value
parent: props.programName, )
parenttype: 'LMS Program', if (!existingMember) {
parentfield: 'program_members', program.value.program_members.push({
member: member.value, member: member.value,
}, })
{ if (props.programName !== 'new') {
onSuccess() { dirty.value = true
updateCounts('member', 'add') }
close() close()
toast.success(__('Member added to program successfully')) toast.success(__('Member added to program successfully'))
}, } else {
onError(err: any) { toast.warning(__('Member already added to program'))
toast.warning(__(err.messages?.[0] || err))
},
} }
)
} }
const updateCounts = async ( const updateCounts = async (
@@ -509,6 +505,15 @@ const updateCounts = async (
const updateOrder = async (e: DragEvent) => { const updateOrder = async (e: DragEvent) => {
let sourceIdx = e.from.dataset.idx let sourceIdx = e.from.dataset.idx
let targetIdx = e.to.dataset.idx let targetIdx = e.to.dataset.idx
if (props.programName === 'new') {
let courses = program.value.program_courses
courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
courses.forEach((course, index) => {
course.idx = index + 1
})
dirty.value = true
} else {
let courses = programCourses.data let courses = programCourses.data
courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0]) courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
@@ -526,48 +531,65 @@ const updateOrder = async (e: DragEvent) => {
) )
await wait(100) await wait(100)
} }
}
} }
const wait = (ms: number) => new Promise((res) => setTimeout(res, ms)) const wait = (ms: number) => new Promise((res) => setTimeout(res, ms))
const remove = async ( const remove = (
selections: string[], selections: string[],
unselectAll: () => void, unselectAll: () => void,
type: string type: string
) => { ) => {
selections = Array.from(selections) const selectionsArray = Array.from(selections)
for (const selection of selections) { if (type === 'courses') {
if (type == 'courses') { program.value.program_courses = program.value.program_courses.filter(
await programCourses.delete.submit(selection) (c: any) => !selectionsArray.includes(c.name || c.course)
await updateCounts('course', 'remove') )
} else { } else {
await programMembers.delete.submit(selection) program.value.program_members = program.value.program_members.filter(
await updateCounts('member', 'remove') (m: any) => !selectionsArray.includes(m.name || m.member)
} )
await programs.value.reload()
await wait(100)
} }
dirty.value = true
unselectAll() unselectAll()
} }
const deleteProgram = (close: () => void) => { const deleteProgram = (close: () => void) => {
if (props.programName == 'new') return if (props.programName == 'new') return
$dialog({
title: __('Delete Program'),
message: __(
'Are you sure you want to delete this program? This action cannot be undone.'
),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick(closeDialog) {
programs.value?.delete.submit(props.programName, { programs.value?.delete.submit(props.programName, {
onSuccess() { onSuccess() {
toast.success(__('Program deleted successfully')) toast.success(__('Program deleted successfully'))
close() close()
closeDialog()
}, },
onError(err: any) { onError(err: any) {
toast.warning(__(err.messages?.[0] || err)) toast.warning(__(err.messages?.[0] || err))
closeDialog()
}, },
}) })
},
},
],
})
} }
const courseColumns = computed(() => { const courseColumns = computed(() => {
return [ return [
{ {
label: 'Title', label: 'Title',
key: 'course_title', key: props.programName === 'new' ? 'course' : 'course_title',
width: 1, width: 1,
}, },
] ]
@@ -31,11 +31,11 @@
categoryColumn: 'category', categoryColumn: 'category',
valueColumn: 'count', valueColumn: 'count',
colors: [ colors: [
theme.colors.red['400'], getColor('red', 400),
theme.colors.amber['400'], getColor('amber', 400),
theme.colors.pink['400'], getColor('pink', 400),
theme.colors.blue['400'], getColor('blue', 400),
theme.colors.green['400'], getColor('green', 400),
], ],
}" }"
/> />
@@ -74,7 +74,7 @@ import {
} from 'frappe-ui' } from 'frappe-ui'
import type { ProgramMember } from '@/types' import type { ProgramMember } from '@/types'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { theme } from '@/utils/theme' import { getColor } from '@/utils'
const show = defineModel<boolean>({ default: false }) const show = defineModel<boolean>({ default: false })
const searchFilter = ref<string | null>(null) const searchFilter = ref<string | null>(null)
+17
View File
@@ -248,6 +248,23 @@ const routes = [
name: 'Search', name: 'Search',
component: () => import('@/pages/Search/Search.vue'), component: () => import('@/pages/Search/Search.vue'),
}, },
{
path: '/data-import',
name: 'DataImportList',
component: () => import('@/pages/DataImport.vue'),
},
{
path: '/data-import/doctype/:doctype',
name: 'NewDataImport',
component: () => import('@/pages/DataImport.vue'),
props: true,
},
{
path: '/data-import/:importName',
name: 'DataImport',
component: () => import('@/pages/DataImport.vue'),
props: true,
},
] ]
let router = createRouter({ let router = createRouter({
-12
View File
@@ -1,7 +1,5 @@
import { io } from 'socket.io-client' import { io } from 'socket.io-client'
import { socketio_port } from '../../../../sites/common_site_config.json' import { socketio_port } from '../../../../sites/common_site_config.json'
import { getCachedListResource } from 'frappe-ui/src/resources/listResource'
import { getCachedResource } from 'frappe-ui/src/resources/resources'
export function initSocket() { export function initSocket() {
let host = window.location.hostname let host = window.location.hostname
@@ -14,15 +12,5 @@ export function initSocket() {
withCredentials: true, withCredentials: true,
reconnectionAttempts: 5, reconnectionAttempts: 5,
}) })
socket.on('refetch_resource', (data) => {
if (data.cache_key) {
let resource =
getCachedResource(data.cache_key) ||
getCachedListResource(data.cache_key)
if (resource) {
resource.reload()
}
}
})
return socket return socket
} }
+499
View File
@@ -0,0 +1,499 @@
{
"lightMode": {
"gray": {
"50": "#F8F8F8",
"100": "#F3F3F3",
"200": "#EDEDED",
"300": "#E2E2E2",
"400": "#C7C7C7",
"500": "#999999",
"600": "#7C7C7C",
"700": "#525252",
"800": "#383838",
"900": "#171717"
},
"blue": {
"50": "#F2F9FF",
"100": "#E6F4FF",
"200": "#C8E6FF",
"300": "#A7D7FD",
"400": "#73BBF6",
"500": "#0289F7",
"600": "#007BE0",
"700": "#0070CC",
"800": "#005CA3",
"900": "#004880"
},
"green": {
"50": "#F2FDF4",
"100": "#E4FAEB",
"200": "#C3F9D3",
"300": "#A6EFC0",
"400": "#86E0A8",
"500": "#46B37E",
"600": "#278F5E",
"700": "#137949",
"800": "#075E35",
"900": "#173B2C"
},
"red": {
"50": "#FFF7F7",
"100": "#FFE7E7",
"200": "#FFD8D8",
"300": "#FDC2C2",
"400": "#F79596",
"500": "#E03636",
"600": "#CC2929",
"700": "#B52A2A",
"800": "#941F1F",
"900": "#6B1515"
},
"amber": {
"50": "#FDFAED",
"100": "#FFF7D3",
"200": "#FEEDA9",
"300": "#FBDB73",
"400": "#FBCC55",
"500": "#E79913",
"600": "#DB7706",
"700": "#B35309",
"800": "#91400D",
"900": "#763813"
},
"orange": {
"50": "#FFF9F5",
"100": "#FFEFE4",
"200": "#FFDEC5",
"300": "#FFCBA3",
"400": "#F4B07F",
"500": "#E86C13",
"600": "#D45A08",
"700": "#BD3E0C",
"800": "#9E3513",
"900": "#6B2711"
},
"yellow": {
"50": "#FFFCEF",
"100": "#FFF7D3",
"200": "#F7E9A8",
"300": "#F5E171",
"400": "#F2D14B",
"500": "#EDBA13",
"600": "#D1930D",
"700": "#AB6E05",
"800": "#8C5600",
"900": "#733F12"
},
"teal": {
"50": "#F0FDFA",
"100": "#E6F7F4",
"200": "#BAE8E1",
"300": "#97DED4",
"400": "#73D1C4",
"500": "#36BAAD",
"600": "#0B9E92",
"700": "#0F736B",
"800": "#115C57",
"900": "#114541"
},
"cyan": {
"50": "#F5FBFC",
"100": "#DDF7FF",
"200": "#B3E8F7",
"300": "#99E2F8",
"400": "#72D5F3",
"500": "#3BBDE5",
"600": "#32A4C7",
"700": "#267A94",
"800": "#125C73",
"900": "#164759"
},
"purple": {
"50": "#FDFAFF",
"100": "#F6E9FF",
"200": "#ECD3FF",
"300": "#E2B9FC",
"400": "#CFA1F2",
"500": "#9C45E3",
"600": "#8642C2",
"700": "#6E399D",
"800": "#5C2F83",
"900": "#401863"
},
"pink": {
"50": "#FFF7FC",
"100": "#FDE8F5",
"200": "#FFD5F0",
"300": "#F9B9E0",
"400": "#F6A7D6",
"500": "#E34AA6",
"600": "#CF3A96",
"700": "#9C2671",
"800": "#801458",
"900": "#570F3E"
},
"violet": {
"50": "#FBFAFF",
"100": "#F0EBFF",
"200": "#DBD5FF",
"300": "#C9BAFB",
"400": "#B3A1F5",
"500": "#6846E3",
"600": "#5F46C7",
"700": "#4F3DA1",
"800": "#392980",
"900": "#251959"
}
},
"darkMode": {
"gray": {
"50": "#F8F8F8",
"100": "#D4D4D4",
"200": "#AFAFAF",
"250": "#999999",
"300": "#808080",
"400": "#717171",
"500": "#424242",
"600": "#343434",
"650": "#2B2B2B",
"700": "#232323",
"800": "#1C1C1C",
"900": "#0F0F0F"
},
"blue": {
"50": "#C9E0F5",
"100": "#ADD2F5",
"200": "#8CC1EC",
"300": "#5AAEF2",
"400": "#3294E3",
"500": "#1580D8",
"600": "#155999",
"700": "#063D71",
"800": "#052B53",
"900": "#0E2037",
"900-80": "#0E2037CC"
},
"green": {
"50": "#C8F3DE",
"100": "#9BE6C1",
"200": "#78D7A9",
"300": "#58C08E",
"400": "#1BA964",
"500": "#0A9752",
"600": "#0F814A",
"700": "#035831",
"800": "#0A3F27",
"900": "#0B2E1C"
},
"red": {
"50": "#FFC1C1",
"100": "#FF9595",
"200": "#FC7474",
"300": "#EB4D52",
"400": "#E43838",
"500": "#C12020",
"600": "#901818",
"700": "#681916",
"800": "#521515",
"900": "#361515",
"800-90": "#521515E6",
"900-90": "#361515E6"
},
"amber": {
"50": "#F9E8A5",
"100": "#F8D16E",
"200": "#F0BA31",
"300": "#E79913",
"400": "#E37D00",
"500": "#CB6D10",
"600": "#824108",
"700": "#603007",
"800": "#4B2606",
"900": "#371E06"
},
"orange": {
"50": "#FFCDAD",
"100": "#FFA873",
"200": "#FA8A40",
"300": "#DE6D1B",
"400": "#C45A0E",
"500": "#984509",
"600": "#823906",
"700": "#683108",
"800": "#532707",
"900": "#401F07",
"900-80": "#401F07CC"
},
"yellow": {
"50": "#FFE89D",
"100": "#F8D76A",
"200": "#ECC02E",
"300": "#DAAE15",
"400": "#C69C12",
"500": "#9C7A0A",
"600": "#705606",
"700": "#5B4605",
"800": "#3F3004",
"900": "#322604"
},
"teal": {
"50": "#93F2E8",
"100": "#6EE7DB",
"200": "#52DACC",
"300": "#3DC6B8",
"400": "#219C8F",
"500": "#1B7169",
"600": "#13564F",
"700": "#0C423C",
"800": "#0B3A35",
"900": "#0A2D29"
},
"cyan": {
"50": "#D0F0FA",
"100": "#A0E6F7",
"200": "#68D3F3",
"300": "#3CB8DC",
"400": "#2B8DAB",
"500": "#23728B",
"600": "#155266",
"700": "#0E3B49",
"800": "#0D2B36",
"900": "#0B252D"
},
"purple": {
"50": "#E5C6FB",
"100": "#D9AFF5",
"200": "#C993EF",
"300": "#B168E8",
"400": "#984BD8",
"500": "#7A2DB9",
"600": "#591F89",
"700": "#47176E",
"800": "#391457",
"900": "#2E1146"
},
"pink": {
"50": "#F6C5DE",
"100": "#F69AD1",
"200": "#ED77BE",
"300": "#E359AB",
"400": "#CB4394",
"500": "#AC377D",
"600": "#822A5F",
"700": "#68204B",
"800": "#601D46",
"900": "#471432",
"900-80": "#471432CC"
},
"violet": {
"50": "#DACBF7",
"100": "#C4AFEE",
"200": "#B398EF",
"300": "#9D7CEA",
"400": "#8867E8",
"500": "#5C3FC2",
"600": "#4639A6",
"700": "#332978",
"800": "#281E5D",
"900": "#221C42"
}
},
"overlay": {
"white": {
"50": "#FFFFFF1A",
"100": "#FFFFFF2E",
"200": "#FFFFFF45",
"300": "#FFFFFF5C",
"400": "#FFFFFF73",
"500": "#FFFFFF8A",
"600": "#FFFFFFA1",
"700": "#FFFFFFB8",
"800": "#FFFFFFCF",
"900": "#FFFFFFE6"
},
"black": {
"50": "#00000017",
"100": "#0000002E",
"200": "#00000045",
"300": "#0000005C",
"400": "#00000073",
"500": "#0000008A",
"600": "#000000A1",
"700": "#000000B8",
"800": "#000000CF",
"900": "#000000E6"
}
},
"neutral": {
"white": "#FFFFFF",
"black": "#000000"
},
"themedVariables": {
"light": {
"outline": {
"white": "neutral/white",
"gray-1": "lightMode/gray/200",
"gray-2": "lightMode/gray/300",
"gray-3": "lightMode/gray/400",
"gray-4": "lightMode/gray/500",
"gray-5": "lightMode/gray/800",
"red-1": "lightMode/red/300",
"red-2": "lightMode/red/400",
"red-3": "lightMode/red/500",
"green-1": "lightMode/green/300",
"green-2": "lightMode/green/400",
"amber-1": "lightMode/amber/300",
"amber-2": "lightMode/amber/400",
"blue-1": "lightMode/blue/300",
"orange-1": "lightMode/orange/400",
"gray-modals": "lightMode/gray/200"
},
"surface": {
"white": "neutral/white",
"gray-1": "lightMode/gray/50",
"gray-2": "lightMode/gray/100",
"gray-3": "lightMode/gray/200",
"gray-4": "lightMode/gray/300",
"gray-5": "lightMode/gray/700",
"gray-6": "lightMode/gray/800",
"gray-7": "lightMode/gray/900",
"red-1": "lightMode/red/50",
"red-2": "lightMode/red/100",
"red-3": "lightMode/red/200",
"red-4": "lightMode/red/300",
"red-5": "lightMode/red/600",
"red-6": "lightMode/red/700",
"red-7": "lightMode/red/800",
"green-1": "lightMode/green/50",
"green-2": "lightMode/green/100",
"green-3": "lightMode/green/600",
"amber-1": "lightMode/amber/50",
"amber-2": "lightMode/amber/100",
"amber-3": "lightMode/amber/600",
"blue-1": "lightMode/blue/50",
"blue-2": "lightMode/blue/100",
"blue-3": "lightMode/blue/600",
"orange-1": "lightMode/orange/100",
"violet-1": "lightMode/violet/100",
"cyan-1": "lightMode/cyan/100",
"pink-1": "lightMode/pink/100",
"menu-bar": "lightMode/gray/50",
"cards": "neutral/white",
"modal": "neutral/white",
"selected": "neutral/white"
},
"ink": {
"white": "neutral/white",
"gray-1": "lightMode/gray/200",
"gray-2": "lightMode/gray/300",
"gray-3": "lightMode/gray/400",
"gray-4": "lightMode/gray/500",
"gray-5": "lightMode/gray/600",
"gray-6": "lightMode/gray/700",
"gray-7": "lightMode/gray/700",
"gray-8": "lightMode/gray/800",
"gray-9": "lightMode/gray/900",
"red-1": "lightMode/red/50",
"red-2": "lightMode/red/400",
"red-3": "lightMode/red/500",
"red-4": "lightMode/red/600",
"green-1": "lightMode/green/50",
"green-2": "lightMode/green/500",
"green-3": "lightMode/green/600",
"amber-1": "lightMode/amber/50",
"amber-2": "lightMode/amber/500",
"amber-3": "lightMode/amber/600",
"blue-1": "lightMode/blue/50",
"blue-2": "lightMode/blue/500",
"blue-3": "lightMode/blue/600",
"cyan-1": "lightMode/cyan/500",
"pink-1": "lightMode/pink/500",
"violet-1": "lightMode/violet/500",
"blue-link": "lightMode/blue/400"
}
},
"dark": {
"outline": {
"white": "darkMode/gray/800",
"gray-1": "darkMode/gray/700",
"gray-2": "darkMode/gray/600",
"gray-3": "darkMode/gray/500",
"gray-4": "darkMode/gray/300",
"gray-5": "lightMode/gray/200",
"red-1": "darkMode/red/800",
"red-2": "darkMode/red/700",
"red-3": "darkMode/red/600",
"green-1": "darkMode/green/800",
"green-2": "darkMode/green/700",
"amber-1": "darkMode/amber/800",
"amber-2": "darkMode/amber/700",
"blue-1": "darkMode/blue/800",
"orange-1": "darkMode/orange/700",
"gray-modals": "darkMode/gray/600"
},
"surface": {
"white": "darkMode/gray/900",
"gray-1": "darkMode/gray/700",
"gray-2": "darkMode/gray/650",
"gray-3": "darkMode/gray/600",
"gray-4": "darkMode/gray/500",
"gray-5": "darkMode/gray/200",
"gray-6": "darkMode/gray/100",
"gray-7": "darkMode/gray/50",
"red-1": "darkMode/red/900",
"red-2": "darkMode/red/900-90",
"red-3": "darkMode/red/800-90",
"red-4": "darkMode/red/700",
"red-5": "darkMode/red/400",
"red-6": "darkMode/red/500",
"red-7": "darkMode/red/600",
"green-1": "darkMode/green/900",
"green-2": "darkMode/green/800",
"green-3": "darkMode/green/400",
"amber-1": "darkMode/amber/900",
"amber-2": "darkMode/amber/800",
"amber-3": "darkMode/amber/400",
"blue-1": "darkMode/blue/900",
"blue-2": "darkMode/blue/800",
"blue-3": "darkMode/blue/400",
"orange-1": "darkMode/orange/900-80",
"violet-1": "darkMode/violet/900",
"cyan-1": "darkMode/cyan/900",
"pink-1": "darkMode/pink/900-80",
"menu-bar": "darkMode/gray/900",
"cards": "darkMode/gray/800",
"modal": "darkMode/gray/700",
"selected": "darkMode/gray/500"
},
"ink": {
"white": "darkMode/gray/900",
"gray-1": "darkMode/gray/700",
"gray-2": "darkMode/gray/500",
"gray-3": "darkMode/gray/400",
"gray-4": "darkMode/gray/400",
"gray-5": "darkMode/gray/300",
"gray-6": "darkMode/gray/250",
"gray-7": "darkMode/gray/200",
"gray-8": "darkMode/gray/100",
"gray-9": "darkMode/gray/50",
"red-1": "neutral/white",
"red-2": "darkMode/red/700",
"red-3": "darkMode/red/400",
"red-4": "darkMode/red/200",
"green-1": "neutral/white",
"green-2": "darkMode/green/400",
"green-3": "darkMode/green/300",
"amber-1": "neutral/white",
"amber-2": "darkMode/amber/400",
"amber-3": "darkMode/amber/300",
"blue-1": "neutral/white",
"blue-2": "darkMode/blue/400",
"blue-3": "darkMode/blue/300",
"cyan-1": "darkMode/cyan/300",
"pink-1": "darkMode/pink/300",
"violet-1": "darkMode/violet/300",
"blue-link": "darkMode/blue/500"
}
}
}
}
+10 -4
View File
@@ -1,6 +1,6 @@
import { call, toast } from 'frappe-ui' import { call, toast } from 'frappe-ui'
import { useTimeAgo } from '@vueuse/core' import { useTimeAgo } from '@vueuse/core'
import { theme } from '@/utils/theme' import colorsJSON from '@/utils/frappe-ui-colors.json'
import { Quiz } from '@/utils/quiz' import { Quiz } from '@/utils/quiz'
import { Program } from '@/utils/program' import { Program } from '@/utils/program'
import { Assignment } from '@/utils/assignment' import { Assignment } from '@/utils/assignment'
@@ -680,7 +680,7 @@ export const getMetaInfo = (type, route, meta) => {
export const updateMetaInfo = (type, route, meta) => { export const updateMetaInfo = (type, route, meta) => {
call('lms.lms.api.update_meta_info', { call('lms.lms.api.update_meta_info', {
type: type, meta_type: type,
route: route, route: route,
meta_tags: [ meta_tags: [
{ key: 'description', value: meta.description }, { key: 'description', value: meta.description },
@@ -734,10 +734,10 @@ const createHighlightSpan = (color, name, scrollIntoView) => {
const span = document.createElement('span') const span = document.createElement('span')
span.className = 'highlighted-text' span.className = 'highlighted-text'
if (scrollIntoView) { if (scrollIntoView) {
span.style.border = `2px solid ${theme.backgroundColor[color][400]}` span.style.border = `2px solid ${getColor(color, 400)}`
span.style.borderRadius = '4px' span.style.borderRadius = '4px'
} else { } else {
span.style.backgroundColor = theme.backgroundColor[color][200] span.style.backgroundColor = getColor(color, 200)
} }
span.dataset.name = name span.dataset.name = name
return span return span
@@ -810,3 +810,9 @@ export const decodeEntities = (encodedString) => {
textarea.innerHTML = encodedString textarea.innerHTML = encodedString
return textarea.value return textarea.value
} }
export const getColor = (color, shade) => {
let theme =
localStorage.getItem('theme') == 'light' ? 'lightMode' : 'darkMode'
return colorsJSON[theme][color][shade]
}
+79 -112
View File
@@ -6,8 +6,8 @@ export class Markdown {
this.api = api this.api = api
this.data = data || {} this.data = data || {}
this.config = config || {} this.config = config || {}
this.text = data.text || ''
this.readOnly = readOnly this.readOnly = readOnly
this.text = data.text || ''
this.placeholder = __("Type '/' for commands or select text to format") this.placeholder = __("Type '/' for commands or select text to format")
} }
@@ -30,65 +30,28 @@ export class Markdown {
const div = document.createElement('div') const div = document.createElement('div')
app.mount(div) app.mount(div)
return { return { title: '', icon: div.innerHTML }
title: '',
icon: div.innerHTML,
}
}
onPaste(event) {
const data = {
text: event.detail.data.innerHTML,
}
this.data = data
window.requestAnimationFrame(() => {
if (!this.wrapper) {
return
}
this.wrapper.innerHTML = this.data.text || ''
})
} }
static get pasteConfig() { static get pasteConfig() {
return { return { tags: ['P'] }
tags: ['P'],
}
} }
render() { render() {
this.wrapper = document.createElement('div') this.wrapper = document.createElement('div')
this.wrapper.classList.add('cdx-block', 'ce-paragraph') this.wrapper.classList.add('cdx-block', 'ce-paragraph')
this.wrapper.contentEditable = !this.readOnly
this.wrapper.dataset.placeholder = this.placeholder
this.wrapper.innerHTML = this.text this.wrapper.innerHTML = this.text
if (!this.readOnly) { if (!this.readOnly) {
this.wrapper.contentEditable = true
this.wrapper.innerHTML = this.text
this.wrapper.addEventListener('focus', () => this.wrapper.addEventListener('focus', () =>
this._togglePlaceholder() this._togglePlaceholder()
) )
this.wrapper.addEventListener('blur', () => this.wrapper.addEventListener('blur', () =>
this._togglePlaceholder() this._togglePlaceholder()
) )
this.wrapper.addEventListener('keydown', (e) => this._onKeyDown(e))
this.wrapper.addEventListener('input', (event) => {
this._togglePlaceholder()
let value = event.target.textContent
if (event.keyCode === 32 && value.startsWith('#')) {
this.convertToHeader(event, value)
} else if (event.keyCode == 189) {
this.convertBlock('list', {
style: 'unordered',
})
} else if (/^[a-zA-Z]/.test(event.key)) {
this.convertBlock('paragraph', {
text: value,
})
} else if (event.keyCode === 13 || event.keyCode === 190) {
this.parseContent(event)
}
})
} }
return this.wrapper return this.wrapper
@@ -99,10 +62,9 @@ export class Markdown {
'.cdx-block.ce-paragraph[data-placeholder]' '.cdx-block.ce-paragraph[data-placeholder]'
) )
blocks.forEach((block) => { blocks.forEach((block) => {
if (block !== this.wrapper) { if (block !== this.wrapper) delete block.dataset.placeholder
delete block.dataset.placeholder
}
}) })
if (this.wrapper.innerHTML.trim() === '') { if (this.wrapper.innerHTML.trim() === '') {
this.wrapper.dataset.placeholder = this.placeholder this.wrapper.dataset.placeholder = this.placeholder
} else { } else {
@@ -110,102 +72,107 @@ export class Markdown {
} }
} }
convertToHeader(event, value) { _onKeyDown(event) {
event.preventDefault() const text = this.wrapper.textContent
if (['#', '##', '###', '####', '#####', '######'].includes(value)) {
let level = value.length
event.target.textContent = ''
this.convertBlock('header', {
level: level,
})
}
}
parseContent(event) { if (event.key === ' ' && /^#{1,6}$/.test(text)) {
event.preventDefault() event.preventDefault()
let previousLine = this.wrapper.textContent const level = text.length
if (event.keyCode === 190) {
previousLine = previousLine + '.'
}
if (previousLine && this.hasImage(previousLine)) {
this.wrapper.textContent = '' this.wrapper.textContent = ''
this.convertBlock('image') this._convertBlock('header', { level })
} else if (previousLine && this.hasLink(previousLine)) { } else if (event.key === ' ' && text === '-') {
const { text, url } = this.extractLink(previousLine) event.preventDefault()
const anchorTag = `<a href="${url}" target="_blank">${text}</a>` this.wrapper.textContent = ''
this.convertBlock('paragraph', { this._convertBlock('list', {
text: previousLine.replace(/\[.+?\]\(.+?\)/, anchorTag),
})
} else if (previousLine && previousLine.startsWith('- ')) {
this.convertBlock('list', {
style: 'unordered', style: 'unordered',
items: [ items: [{ content: '' }],
{
content: previousLine.replace('- ', ''),
},
],
}) })
} else if (previousLine && previousLine.startsWith('1.')) { } else if (event.key === ' ' && /^1\.$/.test(text)) {
this.convertBlock('list', { event.preventDefault()
style: 'ordered',
items: [
{
content: previousLine.replace('1.', ''),
},
],
})
} else if (previousLine && this.canBeEmbed(previousLine)) {
this.wrapper.textContent = '' this.wrapper.textContent = ''
this.convertBlock('embed', { this._convertBlock('list', {
source: previousLine, style: 'ordered',
items: [{ content: '' }],
}) })
} else { } else if (this._isEmbed(text) && event.key === 'Enter') {
this.convertBlock('paragraph', { event.preventDefault()
text: previousLine, this.wrapper.textContent = ''
this._convertBlock('embed', { source: text })
} else if (event.key === 'Enter') {
setTimeout(() => this._checkMarkdownAfterEnter(), 0)
}
}
_checkMarkdownAfterEnter() {
const text = this.wrapper.textContent.trim()
if (this._isImage(text)) {
this._convertBlock('image', {
file: { url: this._extractImage(text).url },
}) })
} }
} }
async convertBlock(type, data, index = null) { async _convertBlock(type, data) {
const currentIndex = this.api.blocks.getCurrentBlockIndex() const currentIndex = this.api.blocks.getCurrentBlockIndex()
const currentBlock = this.api.blocks.getBlockByIndex(currentIndex) const currentBlock = this.api.blocks.getBlockByIndex(currentIndex)
if (!currentBlock) return
await this.api.blocks.convert(currentBlock.id, type, data) await this.api.blocks.convert(currentBlock.id, type, data)
setTimeout(() => {
const newIndex = this.api.blocks.getCurrentBlockIndex()
const newBlock = this.api.blocks.getBlockByIndex(newIndex)
if (newBlock && newBlock.holder) {
const holder = newBlock.holder.querySelector(
'[contenteditable="true"]'
)
if (holder) {
holder.focus()
// Place caret at end
const range = document.createRange()
range.selectNodeContents(holder)
range.collapse(false)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
} else {
this.api.caret.focus(true) this.api.caret.focus(true)
} }
} else {
this.api.caret.focus(true)
}
}, 0)
}
save(blockContent) { save(blockContent) {
return { return { text: blockContent.innerHTML }
text: blockContent.innerHTML,
}
} }
hasImage(line) { _isImage(text) {
return /!\[.+?\]\(.+?\)/.test(line) return /!\[.+?\]\(.+?\)/.test(text)
} }
extractImage(line) { _extractImage(text) {
const match = line.match(/!\[(.+?)\]\((.+?)\)/) const match = text.match(/!\[(.+?)\]\((.+?)\)/)
if (match) { if (match) return { alt: match[1], url: match[2] }
return { alt: match[1], url: match[2] }
}
return { alt: '', url: '' } return { alt: '', url: '' }
} }
hasLink(line) { _isLink(text) {
return /\[.+?\]\(.+?\)/.test(line) return /\[.+?\]\(.+?\)/.test(text)
} }
extractLink(line) { _extractLink(text) {
const match = line.match(/\[(.+?)\]\((.+?)\)/) const match = text.match(/\[(.+?)\]\((.+?)\)/)
if (match) { if (match) return { text: match[1], url: match[2] }
return { text: match[1], url: match[2] }
}
return { text: '', url: '' } return { text: '', url: '' }
} }
canBeEmbed(line) { _isEmbed(text) {
return /^https?:\/\/.+/.test(line.trim()) return /^https?:\/\/.+/.test(text.trim())
} }
} }
-5
View File
@@ -1,5 +0,0 @@
import resolveConfig from 'tailwindcss/resolveConfig'
import tailwindConfig from 'tailwind.config.js'
export const config = resolveConfig(tailwindConfig)
export const theme = config.theme
+1 -1
View File
@@ -1,4 +1,4 @@
import frappeUIPreset from 'frappe-ui/src/tailwind/preset' import frappeUIPreset from 'frappe-ui/tailwind'
export default { export default {
presets: [frappeUIPreset], presets: [frappeUIPreset],
+15 -5
View File
@@ -5,7 +5,7 @@ import frappeui from 'frappe-ui/vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => ({
plugins: [ plugins: [
frappeui({ frappeui({
frappeProxy: true, frappeProxy: true,
@@ -32,6 +32,18 @@ export default defineConfig({
workbox: { workbox: {
cleanupOutdatedCaches: true, cleanupOutdatedCaches: true,
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
globDirectory: '/assets/lms/frontend',
globPatterns: ['**/*.{js,ts,css,html,png,svg}'],
runtimeCaching: [
{
urlPattern: ({ request }) =>
request.destination === 'document',
handler: 'NetworkFirst',
options: {
cacheName: 'html-cache',
},
},
],
}, },
manifest: false, manifest: false,
}), }),
@@ -43,18 +55,16 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, 'src'), '@': path.resolve(__dirname, 'src'),
'tailwind.config.js': path.resolve(__dirname, 'tailwind.config.js'),
}, },
}, },
optimizeDeps: { optimizeDeps: {
include: [ include: [
'feather-icons', 'feather-icons',
'showdown',
'engine.io-client', 'engine.io-client',
'tailwind.config.js',
'interactjs', 'interactjs',
'highlight.js', 'highlight.js',
'plyr', 'plyr',
], ],
exclude: mode === 'production' ? [] : ['frappe-ui'],
}, },
}) }))
+928 -936
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.40.0" __version__ = "2.41.0"
+4 -1
View File
@@ -104,7 +104,10 @@ doc_events = {
"lms.lms.doctype.lms_badge.lms_badge.process_badges", "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",
"validate": "lms.lms.utils.validate_discussion_reply",
},
"Notification Log": {"on_change": "lms.lms.utils.publish_notifications"}, "Notification Log": {"on_change": "lms.lms.utils.publish_notifications"},
"User": { "User": {
"validate": "lms.lms.user.validate_username_duplicates", "validate": "lms.lms.user.validate_username_duplicates",
@@ -14,7 +14,6 @@
"type", "type",
"work_mode", "work_mode",
"status", "status",
"disabled",
"section_break_6", "section_break_6",
"company_name", "company_name",
"company_website", "company_website",
@@ -97,12 +96,6 @@
"label": "Company Logo", "label": "Company Logo",
"reqd": 1 "reqd": 1
}, },
{
"default": "0",
"fieldname": "disabled",
"fieldtype": "Check",
"label": "Disabled"
},
{ {
"fieldname": "company_email_address", "fieldname": "company_email_address",
"fieldtype": "Data", "fieldtype": "Data",
@@ -137,8 +130,8 @@
} }
], ],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2025-09-24 15:32:49.030004", "modified": "2025-12-02 16:58:49.903274",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "Job", "module": "Job",
"name": "Job Opportunity", "name": "Job Opportunity",
"owner": "Administrator", "owner": "Administrator",
+62 -8
View File
@@ -520,7 +520,7 @@ def get_sidebar_settings():
web_pages = frappe.get_all( web_pages = frappe.get_all(
"LMS Sidebar Item", "LMS Sidebar Item",
{"parenttype": "LMS Settings", "parentfield": "sidebar_items"}, {"parenttype": "LMS Settings", "parentfield": "sidebar_items"},
["web_page", "route", "title as label", "icon"], ["web_page", "route", "title as label", "icon", "name"],
) )
for page in web_pages: for page in web_pages:
page.to = page.route page.to = page.route
@@ -1014,6 +1014,7 @@ def give_discussions_permission():
"write": 1, "write": 1,
"create": 1, "create": 1,
"delete": 1, "delete": 1,
"if_owner": 0 if role == "Moderator" else 1,
} }
).save(ignore_permissions=True) ).save(ignore_permissions=True)
@@ -1303,7 +1304,24 @@ def get_notifications(filters):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_lms_setting(field): def get_lms_setting(field=None):
if not field:
frappe.throw(_("Field name is required"))
frappe.log_error("Field name is missing when accessing LMS Settings {0} {1} {2}").format(
frappe.local.request_ip, frappe.get_request_header("Referer"), frappe.get_request_header("Origin")
)
allowed_fields = [
"allow_guest_access",
"prevent_skipping_videos",
"contact_us_email",
"contact_us_url",
"livecode_url",
]
if field not in allowed_fields:
frappe.throw(_("You are not allowed to access this field"))
return frappe.get_cached_value("LMS Settings", None, field) return frappe.get_cached_value("LMS Settings", None, field)
@@ -1451,11 +1469,11 @@ def get_meta_info(type, route):
@frappe.whitelist() @frappe.whitelist()
def update_meta_info(type, route, meta_tags): def update_meta_info(meta_type, route, meta_tags):
parent_name = f"{type}/{route}" validate_meta_data_permissions(meta_type)
if not isinstance(meta_tags, list): validate_meta_tags(meta_tags)
frappe.throw(_("Meta tags should be a list."))
parent_name = f"{meta_type}/{route}"
for tag in meta_tags: for tag in meta_tags:
existing_tag = frappe.db.exists( existing_tag = frappe.db.exists(
"Website Meta Tag", "Website Meta Tag",
@@ -1482,6 +1500,17 @@ def update_meta_info(type, route, meta_tags):
parent_exists = frappe.db.exists("Website Route Meta", parent_name) parent_exists = frappe.db.exists("Website Route Meta", parent_name)
if not parent_exists: if not parent_exists:
create_meta(parent_name, tag_properties)
else:
create_meta_tag(tag_properties)
def validate_meta_tags(meta_tags):
if not isinstance(meta_tags, list):
frappe.throw(_("Meta tags should be a list."))
def create_meta(parent_name, tag_properties):
route_meta = frappe.new_doc("Website Route Meta") route_meta = frappe.new_doc("Website Route Meta")
route_meta.update( route_meta.update(
{ {
@@ -1490,12 +1519,26 @@ def update_meta_info(type, route, meta_tags):
) )
route_meta.append("meta_tags", tag_properties) route_meta.append("meta_tags", tag_properties)
route_meta.insert() route_meta.insert()
else:
def create_meta_tag(tag_properties):
new_tag = frappe.new_doc("Website Meta Tag") new_tag = frappe.new_doc("Website Meta Tag")
new_tag.update(tag_properties) new_tag.update(tag_properties)
new_tag.insert() new_tag.insert()
def validate_meta_data_permissions(meta_type):
roles = frappe.get_roles()
if meta_type == "courses":
if not ("Course Creator" in roles or "Moderator" in roles):
frappe.throw(_("You do not have permission to update meta tags."))
elif meta_type == "batches":
if not ("Batch Evaluator" in roles or "Moderator" in roles):
frappe.throw(_("You do not have permission to update meta tags."))
@frappe.whitelist() @frappe.whitelist()
def create_programming_exercise_submission(exercise, submission, code, test_cases): def create_programming_exercise_submission(exercise, submission, code, test_cases):
if submission == "new": if submission == "new":
@@ -1678,7 +1721,18 @@ def get_profile_details(username):
details = frappe.db.get_value( details = frappe.db.get_value(
"User", "User",
{"username": username}, {"username": username},
["full_name", "name", "username", "user_image", "bio", "headline", "cover_image"], [
"first_name",
"last_name",
"full_name",
"name",
"username",
"user_image",
"bio",
"headline",
"language",
"cover_image",
],
as_dict=True, as_dict=True,
) )
@@ -7,12 +7,12 @@
"doctype": "Dashboard Chart", "doctype": "Dashboard Chart",
"document_type": "LMS Certificate", "document_type": "LMS Certificate",
"dynamic_filters_json": "[]", "dynamic_filters_json": "[]",
"filters_json": "[[\"LMS Certificate\",\"published\",\"=\",1,false]]", "filters_json": "[[\"LMS Certificate\",\"published\",\"=\",1]]",
"group_by_type": "Count", "group_by_type": "Count",
"idx": 0, "idx": 0,
"is_public": 1, "is_public": 1,
"is_standard": 1, "is_standard": 1,
"modified": "2025-04-28 17:47:28.517149", "modified": "2025-12-07 17:47:28.517150",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "Certification", "name": "Certification",
@@ -9,19 +9,20 @@
"doctype": "Dashboard Chart", "doctype": "Dashboard Chart",
"document_type": "User", "document_type": "User",
"dynamic_filters_json": "[]", "dynamic_filters_json": "[]",
"filters_json": "[[\"User\",\"enabled\",\"=\",1,false]]", "filters_json": "[[\"User\",\"enabled\",\"=\",1]]",
"group_by_type": "Count", "group_by_type": "Count",
"idx": 5, "idx": 5,
"is_public": 1, "is_public": 1,
"is_standard": 1, "is_standard": 1,
"last_synced_on": "2025-04-28 15:09:52.161688", "last_synced_on": "2025-12-08 13:05:16.186243",
"modified": "2025-04-28 17:47:58.168293", "modified": "2025-12-09 13:08:50.049053",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "New Signups", "name": "New Signups",
"number_of_groups": 0, "number_of_groups": 0,
"owner": "basawaraj@erpnext.com", "owner": "basawaraj@erpnext.com",
"roles": [], "roles": [],
"show_values_over_chart": 0,
"source": "", "source": "",
"time_interval": "Daily", "time_interval": "Daily",
"timeseries": 1, "timeseries": 1,
@@ -4,7 +4,7 @@
import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from lms.lms.utils import has_course_instructor_role, has_course_moderator_role from lms.lms.utils import has_course_instructor_role, has_moderator_role
class LMSAssignment(Document): class LMSAssignment(Document):
@@ -13,7 +13,7 @@ class LMSAssignment(Document):
@frappe.whitelist() @frappe.whitelist()
def save_assignment(assignment, title, type, question): def save_assignment(assignment, title, type, question):
if not has_course_moderator_role() or not has_course_instructor_role(): if not has_moderator_role() or not has_course_instructor_role():
return return
if assignment: if assignment:
+1 -3
View File
@@ -30,9 +30,7 @@ frappe.ui.form.on("LMS Badge", {
const user_fields = fields const user_fields = fields
.filter( .filter(
(df) => (df) => df.fieldtype === "Link" && df.options === "User"
(df.fieldtype === "Link" && df.options === "User") ||
df.fieldtype === "Data"
) )
.map(map_for_options) .map(map_for_options)
.concat([ .concat([
@@ -84,7 +84,7 @@
"grid_page_length": 50, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-11-10 11:39:42.233779", "modified": "2025-12-04 17:06:26.090276",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Badge Assignment", "name": "LMS Badge Assignment",
@@ -116,25 +116,13 @@
}, },
{ {
"create": 1, "create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 1, "if_owner": 1,
"print": 1,
"read": 1, "read": 1,
"report": 1, "role": "LMS Student"
"role": "LMS Student",
"share": 1,
"write": 1
}, },
{ {
"email": 1,
"export": 1,
"print": 1,
"read": 1, "read": 1,
"report": 1, "role": "LMS Student"
"role": "LMS Student",
"share": 1
}, },
{ {
"create": 1, "create": 1,
@@ -1,9 +1,64 @@
# Copyright (c) 2024, Frappe and contributors # Copyright (c) 2024, Frappe and contributors
# For license information, please see license.txt # For license information, please see license.txt
# import frappe import frappe
from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from lms.lms.doctype.lms_badge.lms_badge import eval_condition
class LMSBadgeAssignment(Document): class LMSBadgeAssignment(Document):
pass def validate(self):
self.validate_owner()
self.validate_duplicate_badge_assignment()
self.validate_badge_criteria()
def validate_owner(self):
if self.owner == self.member:
return
roles = frappe.get_roles(self.owner)
if "Moderator" not in roles:
frappe.throw(_("You must be a Moderator to assign badges to users."))
def validate_duplicate_badge_assignment(self):
grant_only_once = frappe.db.get_value("LMS Badge", self.badge, "grant_only_once")
if not grant_only_once:
return
if frappe.db.exists(
"LMS Badge Assignment",
{"badge": self.badge, "member": self.member, "name": ["!=", self.name]},
):
frappe.throw(
_("Badge {0} has already been assigned to this {1}.").format(self.badge, self.member)
)
def validate_badge_criteria(self):
badge_details = frappe.db.get_value(
"LMS Badge", self.badge, ["reference_doctype", "user_field", "condition", "enabled"], as_dict=True
)
if badge_details:
if badge_details.reference_doctype and badge_details.user_field and badge_details.condition:
user_fieldname = frappe.db.get_value(
"DocField",
{"parent": badge_details.reference_doctype, "fieldname": badge_details.user_field},
"fieldname",
)
documents = frappe.get_all(
badge_details.reference_doctype,
{user_fieldname: self.member},
)
for document in documents:
reference_value = eval_condition(
frappe.get_doc(badge_details.reference_doctype, document.name),
badge_details.condition,
)
if reference_value:
return
frappe.throw(_("Member does not meet the criteria for the badge {0}.").format(self.badge))
+2 -7
View File
@@ -379,7 +379,7 @@
"link_fieldname": "batch_name" "link_fieldname": "batch_name"
} }
], ],
"modified": "2025-05-26 15:30:55.083507", "modified": "2025-12-04 12:54:11.190967",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Batch", "name": "LMS Batch",
@@ -422,13 +422,8 @@
"write": 1 "write": 1
}, },
{ {
"email": 1,
"export": 1,
"print": 1,
"read": 1, "read": 1,
"report": 1, "role": "LMS Student"
"role": "LMS Student",
"share": 1
} }
], ],
"row_format": "Dynamic", "row_format": "Dynamic",
@@ -73,8 +73,8 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-02-11 10:39:57.259526", "modified": "2025-12-04 12:53:38.246250",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Batch Enrollment", "name": "LMS Batch Enrollment",
"owner": "Administrator", "owner": "Administrator",
@@ -105,16 +105,12 @@
}, },
{ {
"create": 1, "create": 1,
"email": 1,
"export": 1,
"if_owner": 1, "if_owner": 1,
"print": 1,
"read": 1, "read": 1,
"report": 1, "role": "LMS Student"
"role": "LMS Student",
"share": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
@@ -15,9 +15,19 @@ class LMSBatchEnrollment(Document):
self.add_member_to_live_class() self.add_member_to_live_class()
def validate(self): def validate(self):
self.validate_owner()
self.validate_duplicate_members() self.validate_duplicate_members()
self.validate_seat_availability()
self.validate_course_enrollment() self.validate_course_enrollment()
def validate_owner(self):
if self.owner == self.member:
return
roles = frappe.get_roles(self.owner)
if not ("Moderator" in roles or "Batch Evaluator" in roles):
frappe.throw(_("You must be a Moderator or Batch Evaluator to enroll users in a batch."))
def validate_duplicate_members(self): def validate_duplicate_members(self):
if frappe.db.exists( if frappe.db.exists(
"LMS Batch Enrollment", "LMS Batch Enrollment",
@@ -25,6 +35,12 @@ class LMSBatchEnrollment(Document):
): ):
frappe.throw(_("Member already enrolled in this batch")) frappe.throw(_("Member already enrolled in this batch"))
def validate_seat_availability(self):
seat_count = frappe.db.get_value("LMS Batch", self.batch, "seat_count")
enrolled_count = frappe.db.count("LMS Batch Enrollment", {"batch": self.batch})
if seat_count and enrolled_count >= seat_count:
frappe.throw(_("There are no seats available in this batch."))
def validate_course_enrollment(self): def validate_course_enrollment(self):
courses = frappe.get_all("Batch Course", filters={"parent": self.batch}, fields=["course"]) courses = frappe.get_all("Batch Course", filters={"parent": self.batch}, fields=["course"])
@@ -1,5 +1,6 @@
{ {
"actions": [], "actions": [],
"allow_import": 1,
"allow_rename": 1, "allow_rename": 1,
"autoname": "field:category", "autoname": "field:category",
"creation": "2023-06-15 12:40:36.484165", "creation": "2023-06-15 12:40:36.484165",
@@ -21,8 +22,8 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-03-19 12:12:23.723432", "modified": "2025-11-08 19:28:28.468137",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Category", "name": "LMS Category",
"naming_rule": "By fieldname", "naming_rule": "By fieldname",
@@ -73,6 +74,7 @@
"share": 1 "share": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
@@ -19,7 +19,7 @@ class LMSCertificate(Document):
self.name = make_autoname("hash", self.doctype) self.name = make_autoname("hash", self.doctype)
def after_insert(self): def after_insert(self):
if not frappe.flags.in_test: if not frappe.in_test:
outgoing_email_account = frappe.get_cached_value( outgoing_email_account = frappe.get_cached_value(
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name" "Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
) )
@@ -115,14 +115,28 @@ def has_website_permission(doc, ptype, user, verbose=False):
@frappe.whitelist() @frappe.whitelist()
def create_certificate(course): def create_certificate(course):
certificate = is_certified(course) if is_certified(course):
if certificate:
return frappe.db.get_value( return frappe.db.get_value(
"LMS Certificate", certificate, ["name", "course", "template"], as_dict=True "LMS Certificate", certificate, ["name", "course", "template"], as_dict=True
) )
else: else:
validate_certification_eligibility(course)
default_certificate_template = get_default_certificate_template()
certificate = frappe.get_doc(
{
"doctype": "LMS Certificate",
"member": frappe.session.user,
"course": course,
"issue_date": nowdate(),
"template": default_certificate_template,
}
)
certificate.save(ignore_permissions=True)
return certificate
def get_default_certificate_template():
default_certificate_template = frappe.db.get_value( default_certificate_template = frappe.db.get_value(
"Property Setter", "Property Setter",
{ {
@@ -138,14 +152,19 @@ def create_certificate(course):
"doc_type": "LMS Certificate", "doc_type": "LMS Certificate",
}, },
) )
certificate = frappe.get_doc(
{ return default_certificate_template
"doctype": "LMS Certificate",
"member": frappe.session.user,
"course": course, def validate_certification_eligibility(course):
"issue_date": nowdate(), if not frappe.db.exists("LMS Enrollment", {"course": course, "member": frappe.session.user}):
"template": default_certificate_template, frappe.throw(_("You are not enrolled in this course."))
}
if not frappe.db.get_value("LMS Course", course, "enable_certification"):
frappe.throw(_("Certification is not enabled for this course."))
progress = frappe.db.get_value(
"LMS Enrollment", {"course": course, "member": frappe.session.user}, "progress"
) )
certificate.save(ignore_permissions=True) if progress < 100:
return certificate frappe.throw(_("You have not completed the course yet."))
@@ -4,7 +4,7 @@
import unittest import unittest
import frappe import frappe
from frappe.utils import add_years, cint, nowdate from frappe.utils import cint, nowdate
from lms.lms.doctype.lms_certificate.lms_certificate import create_certificate from lms.lms.doctype.lms_certificate.lms_certificate import create_certificate
from lms.lms.doctype.lms_course.test_lms_course import new_course from lms.lms.doctype.lms_course.test_lms_course import new_course
@@ -18,6 +18,7 @@ class TestLMSCertificate(unittest.TestCase):
"enable_certification": 1, "enable_certification": 1,
}, },
) )
create_enrollment(course.name)
certificate = create_certificate(course.name) certificate = create_certificate(course.name)
self.assertEqual(certificate.member, "Administrator") self.assertEqual(certificate.member, "Administrator")
@@ -26,3 +27,11 @@ class TestLMSCertificate(unittest.TestCase):
frappe.db.delete("LMS Certificate", certificate.name) frappe.db.delete("LMS Certificate", certificate.name)
frappe.db.delete("LMS Course", course.name) frappe.db.delete("LMS Course", course.name)
def create_enrollment(course):
enrollment = frappe.new_doc("LMS Enrollment")
enrollment.course = course
enrollment.member = frappe.session.user
enrollment.progress = cint(100)
enrollment.save()
@@ -6,7 +6,7 @@ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc from frappe.model.mapper import get_mapped_doc
from lms.lms.utils import has_course_moderator_role from lms.lms.utils import has_moderator_role
class LMSCertificateEvaluation(Document): class LMSCertificateEvaluation(Document):
@@ -19,7 +19,7 @@ class LMSCertificateEvaluation(Document):
def has_website_permission(doc, ptype, user, verbose=False): def has_website_permission(doc, ptype, user, verbose=False):
if has_course_moderator_role() or doc.member == frappe.session.user: if has_moderator_role() or doc.member == frappe.session.user:
return True return True
return False return False
@@ -30,6 +30,7 @@ class TestLMSCourse(unittest.TestCase):
frappe.delete_doc("User", "tester@example.com") frappe.delete_doc("User", "tester@example.com")
if frappe.db.exists("LMS Course", "test-course"): if frappe.db.exists("LMS Course", "test-course"):
frappe.db.delete("Batch Course", {"course": "test-course"})
frappe.db.delete("Exercise Submission", {"course": "test-course"}) frappe.db.delete("Exercise Submission", {"course": "test-course"})
frappe.db.delete("Exercise Latest Submission", {"course": "test-course"}) frappe.db.delete("Exercise Latest Submission", {"course": "test-course"})
frappe.db.delete("LMS Exercise", {"course": "test-course"}) frappe.db.delete("LMS Exercise", {"course": "test-course"})
@@ -77,8 +77,7 @@ def update_program_progress(member):
@frappe.whitelist() @frappe.whitelist()
def create_membership(course, batch=None, member=None, member_type="Student", role="Member"): def create_membership(course, batch=None, member=None, member_type="Student", role="Member"):
if frappe.db.get_value("LMS Course", course, "disable_self_learning"): validate_course_enrollment_eligibility(course, member)
return False
enrollment = frappe.new_doc("LMS Enrollment") enrollment = frappe.new_doc("LMS Enrollment")
enrollment.update( enrollment.update(
@@ -95,6 +94,42 @@ def create_membership(course, batch=None, member=None, member_type="Student", ro
return enrollment return enrollment
def validate_course_enrollment_eligibility(course, member):
if not member:
member = frappe.session.user
course_details = frappe.db.get_value(
"LMS Course",
course,
["published", "disable_self_learning", "paid_course", "paid_certificate"],
as_dict=True,
)
if course_details.disable_self_learning:
frappe.throw(
_(
"You cannot enroll in this course as self-learning is disabled. Please contact the Administrator."
)
)
if not course_details.published:
frappe.throw(_("You cannot enroll in an unpublished course."))
if course_details.paid_course:
payment = frappe.db.exists(
"LMS Payment",
{
"reference_doctype": "LMS Course",
"reference_docname": course,
"member": member,
"payment_receipt": True,
},
)
if not payment:
frappe.throw(_("You need to complete the payment for this course before enrolling."))
@frappe.whitelist() @frappe.whitelist()
def update_current_membership(batch, course, member): def update_current_membership(batch, course, member):
all_memberships = frappe.get_all("LMS Enrollment", {"member": member, "course": course}) all_memberships = frappe.get_all("LMS Enrollment", {"member": member, "course": course})
@@ -9,60 +9,15 @@ from lms.lms.doctype.lms_course.test_lms_course import new_course, new_user
class TestLMSEnrollment(unittest.TestCase): class TestLMSEnrollment(unittest.TestCase):
def setUp(self):
frappe.db.delete("LMS Enrollment")
frappe.db.delete("LMS Batch Old")
frappe.db.delete("LMS Course Mentor Mapping")
frappe.db.delete("User", {"email": ("like", "%@test.com")})
def new_course_batch(self):
course = new_course("Test Course")
new_user("Test Mentor", "mentor@test.com")
# without this, the creating batch will fail
course.add_mentor("mentor@test.com")
frappe.session.user = "mentor@test.com"
batch = frappe.get_doc(
{
"doctype": "LMS Batch Old",
"name": "test-batch",
"title": "Test Batch",
"course": course.name,
}
)
batch.insert(ignore_permissions=True)
frappe.session.user = "Administrator"
return course, batch
def add_membership(self, batch_name, member_name, course, member_type="Student"):
doc = frappe.get_doc(
{
"doctype": "LMS Enrollment",
"batch_old": batch_name,
"member": member_name,
"member_type": member_type,
"course": course,
}
)
doc.insert()
return doc
def test_membership(self): def test_membership(self):
course, batch = self.new_course_batch() course = new_course("Test Enrollment")
member = new_user("Test", "test01@test.com") enrollment = frappe.new_doc("LMS Enrollment")
membership = self.add_membership(batch.name, member.name, course.name) enrollment.course = course.name
enrollment.member = frappe.session.user
assert membership.course == course.name enrollment.save()
assert membership.member_name == member.full_name
def test_membership_change_role(self): self.assertEqual(enrollment.course, course.name)
course, batch = self.new_course_batch() self.assertEqual(enrollment.member, "Administrator")
member = new_user("Test", "test01@test.com") frappe.db.delete("LMS Enrollment", enrollment.name)
membership = self.add_membership(batch.name, member.name, course.name) frappe.db.delete("LMS Course", course.name)
# it should be possible to change role
membership.role = "Admin"
membership.save()
@@ -3,52 +3,8 @@
import unittest import unittest
import frappe # import frappe
from lms.lms.doctype.lms_course.test_lms_course import new_course
class TestLMSExercise(unittest.TestCase): class TestLMSExercise(unittest.TestCase):
def new_exercise(self): pass
course = new_course("Test Course")
member = frappe.get_doc(
{
"doctype": "LMS Enrollment",
"course": course.name,
"member": frappe.session.user,
}
)
member.insert()
e = frappe.get_doc(
{
"doctype": "LMS Exercise",
"name": "test-problem",
"course": course.name,
"title": "Test Problem",
"description": "draw a circle",
"code": "# draw a single cicle",
"answer": ("# draw a single circle\n" + "circle(100, 100, 50)"),
}
)
e.insert()
return e
def test_exercise(self):
e = self.new_exercise()
assert e.get_user_submission() is None
def test_exercise_submission(self):
e = self.new_exercise()
submission = e.submit("circle(100, 100, 50)")
assert submission is not None
assert submission.exercise == e.name
assert submission.course == e.course
user_submission = e.get_user_submission()
assert user_submission is not None
assert user_submission.name == submission.name
def tearDown(self):
frappe.db.delete("LMS Enrollment")
frappe.db.delete("Exercise Submission")
frappe.db.delete("LMS Exercise")
+2 -7
View File
@@ -92,7 +92,7 @@
"grid_page_length": 50, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-08-20 12:28:57.238902", "modified": "2025-12-04 12:56:14.249363",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Program", "name": "LMS Program",
@@ -136,13 +136,8 @@
"write": 1 "write": 1
}, },
{ {
"email": 1,
"export": 1,
"print": 1,
"read": 1, "read": 1,
"report": 1, "role": "LMS Student"
"role": "LMS Student",
"share": 1
} }
], ],
"row_format": "Dynamic", "row_format": "Dynamic",
+2 -2
View File
@@ -17,7 +17,7 @@ class LMSProgram(Document):
duplicates = {course for course in courses if courses.count(course) > 1} duplicates = {course for course in courses if courses.count(course) > 1}
if len(duplicates): if len(duplicates):
frappe.throw( frappe.throw(
_("Course {0} has already been added to this batch.").format( _("Course {0} has already been added to this program.").format(
frappe.bold(next(iter(duplicates))) frappe.bold(next(iter(duplicates)))
) )
) )
@@ -27,7 +27,7 @@ class LMSProgram(Document):
duplicates = {member for member in members if members.count(member) > 1} duplicates = {member for member in members if members.count(member) > 1}
if len(duplicates): if len(duplicates):
frappe.throw( frappe.throw(
_("Member {0} has already been added to this batch.").format( _("Member {0} has already been added to this program.").format(
frappe.bold(next(iter(duplicates))) frappe.bold(next(iter(duplicates)))
) )
) )
+2 -2
View File
@@ -5,7 +5,7 @@ import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from lms.lms.utils import has_course_instructor_role, has_course_moderator_role from lms.lms.utils import has_course_instructor_role, has_moderator_role
class LMSQuestion(Document): class LMSQuestion(Document):
@@ -95,7 +95,7 @@ def get_correct_options(question):
@frappe.whitelist() @frappe.whitelist()
def get_question_details(question): def get_question_details(question):
if not has_course_instructor_role() or not has_course_moderator_role(): if not has_course_instructor_role() or not has_moderator_role():
return return
fields = ["question", "type", "name"] fields = ["question", "type", "name"]
@@ -352,7 +352,7 @@
"options": "Email Template" "options": "Email Template"
}, },
{ {
"default": "0", "default": "1",
"fieldname": "disable_signup", "fieldname": "disable_signup",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Disable Signup" "label": "Disable Signup"
@@ -444,7 +444,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-10-07 19:22:48.705933", "modified": "2025-12-02 12:21:15.832799",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Settings", "name": "LMS Settings",
+139 -39
View File
@@ -13,7 +13,6 @@ from frappe.desk.notifications import extract_mentions
from frappe.rate_limiter import rate_limit from frappe.rate_limiter import rate_limit
from frappe.utils import ( from frappe.utils import (
add_months, add_months,
ceil,
cint, cint,
cstr, cstr,
flt, flt,
@@ -25,6 +24,7 @@ from frappe.utils import (
getdate, getdate,
nowtime, nowtime,
pretty_date, pretty_date,
rounded,
) )
from lms.lms.md import find_macros, markdown_to_html from lms.lms.md import find_macros, markdown_to_html
@@ -201,7 +201,7 @@ def get_lesson_icon(body, content):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@rate_limit(limit=50, seconds=60 * 60) @rate_limit(limit=500, seconds=60 * 60)
def get_tags(course): def get_tags(course):
tags = frappe.db.get_value("LMS Course", course, "tags") tags = frappe.db.get_value("LMS Course", course, "tags")
return tags.split(",") if tags else [] return tags.split(",") if tags else []
@@ -246,7 +246,7 @@ def get_average_rating(course):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@rate_limit(limit=50, seconds=60 * 60) @rate_limit(limit=500, seconds=60 * 60)
def get_reviews(course): def get_reviews(course):
reviews = frappe.get_all( reviews = frappe.get_all(
"LMS Course Review", "LMS Course Review",
@@ -492,7 +492,7 @@ def can_create_courses(course, member=None):
if frappe.session.user == "Guest": if frappe.session.user == "Guest":
return False return False
if has_course_moderator_role(member): if has_moderator_role(member):
return True return True
if has_course_instructor_role(member) and member in instructors: if has_course_instructor_role(member) and member in instructors:
@@ -504,7 +504,18 @@ def can_create_courses(course, member=None):
return False return False
def has_course_moderator_role(member=None): def can_create_batches(member=None):
if not member:
member = frappe.session.user
if has_moderator_role(member):
return True
if has_evaluator_role(member):
return True
return False
def has_moderator_role(member=None):
return frappe.db.get_value( return frappe.db.get_value(
"Has Role", "Has Role",
{"parent": member or frappe.session.user, "role": "Moderator"}, {"parent": member or frappe.session.user, "role": "Moderator"},
@@ -512,7 +523,7 @@ def has_course_moderator_role(member=None):
) )
def has_course_evaluator_role(member=None): def has_evaluator_role(member=None):
return frappe.db.get_value( return frappe.db.get_value(
"Has Role", "Has Role",
{"parent": member or frappe.session.user, "role": "Batch Evaluator"}, {"parent": member or frappe.session.user, "role": "Batch Evaluator"},
@@ -737,7 +748,7 @@ def has_lessons(course):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@rate_limit(limit=50, seconds=60 * 60) @rate_limit(limit=500, seconds=60 * 60)
def get_chart_data( def get_chart_data(
chart_name, chart_name,
timespan="Select Date Range", timespan="Select Date Range",
@@ -758,17 +769,18 @@ def get_chart_data(
datefield = chart.based_on datefield = chart.based_on
value_field = chart.value_based_on or "1" value_field = chart.value_based_on or "1"
filters = [([chart.document_type, "docstatus", "<", 2, False])] filters = [([chart.document_type, "docstatus", "<", 2])]
print(chart.filters_json)
filters = filters + json.loads(chart.filters_json) filters = filters + json.loads(chart.filters_json)
filters.append([doctype, datefield, ">=", from_date, False]) filters.append([doctype, datefield, ">=", from_date])
filters.append([doctype, datefield, "<=", to_date, False]) filters.append([doctype, datefield, "<=", to_date])
data = frappe.db.get_all( data = frappe.db.get_all(
doctype, doctype,
fields=[f"{datefield} as _unit", f"SUM({value_field})", "COUNT(*)"], fields=[datefield, {"SUM": value_field}, {"COUNT": "*"}],
filters=filters, filters=filters,
group_by="_unit", group_by=datefield,
order_by="_unit asc", order_by=datefield,
as_list=True, as_list=True,
) )
@@ -785,7 +797,7 @@ def get_chart_data(
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@rate_limit(limit=50, seconds=60 * 60) @rate_limit(limit=500, seconds=60 * 60)
def get_course_completion_data(): def get_course_completion_data():
all_membership = frappe.db.count("LMS Enrollment") all_membership = frappe.db.count("LMS Enrollment")
completed = frappe.db.count("LMS Enrollment", {"progress": ["like", "%100%"]}) completed = frappe.db.count("LMS Enrollment", {"progress": ["like", "%100%"]})
@@ -812,7 +824,7 @@ def get_telemetry_boot_info():
@frappe.whitelist() @frappe.whitelist()
def is_onboarding_complete(): def is_onboarding_complete():
if not has_course_moderator_role(): if not has_moderator_role():
return {"is_onboarded": True} return {"is_onboarded": True}
course_created = frappe.db.a_row_exists("LMS Course") course_created = frappe.db.a_row_exists("LMS Course")
@@ -928,7 +940,7 @@ def check_multicurrency(amount, currency, country=None, amount_usd=None):
if apply_rounding and amount % 100 != 0: if apply_rounding and amount % 100 != 0:
amount = amount + 100 - amount % 100 amount = amount + 100 - amount % 100
return ceil(amount), currency return rounded(amount), currency
def apply_gst(amount, country=None): def apply_gst(amount, country=None):
@@ -961,7 +973,7 @@ def change_currency(amount, currency, country=None):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@rate_limit(limit=50, seconds=60 * 60) @rate_limit(limit=500, seconds=60 * 60)
def get_courses(filters=None, start=0): def get_courses(filters=None, start=0):
"""Returns the list of courses.""" """Returns the list of courses."""
@@ -1102,7 +1114,7 @@ def get_course_fields():
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@rate_limit(limit=50, seconds=60 * 60) @rate_limit(limit=500, seconds=60 * 60)
def get_course_details(course): def get_course_details(course):
course_details = frappe.db.get_value( course_details = frappe.db.get_value(
"LMS Course", "LMS Course",
@@ -1197,7 +1209,6 @@ def get_categorized_courses(courses):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@rate_limit(limit=50, seconds=60 * 60)
def get_course_outline(course, progress=False): def get_course_outline(course, progress=False):
"""Returns the course outline.""" """Returns the course outline."""
outline = [] outline = []
@@ -1225,7 +1236,7 @@ def get_course_outline(course, progress=False):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@rate_limit(limit=50, seconds=60 * 60) @rate_limit(limit=500, seconds=60 * 60)
def get_lesson(course, chapter, lesson): def get_lesson(course, chapter, lesson):
chapter_name = frappe.db.get_value("Chapter Reference", {"parent": course, "idx": chapter}, "chapter") chapter_name = frappe.db.get_value("Chapter Reference", {"parent": course, "idx": chapter}, "chapter")
lesson_name = frappe.db.get_value("Lesson Reference", {"parent": chapter_name, "idx": lesson}, "lesson") lesson_name = frappe.db.get_value("Lesson Reference", {"parent": chapter_name, "idx": lesson}, "lesson")
@@ -1256,7 +1267,7 @@ def get_lesson(course, chapter, lesson):
if ( if (
not lesson_details.include_in_preview not lesson_details.include_in_preview
and not membership and not membership
and not has_course_moderator_role() and not has_moderator_role()
and not is_instructor(course) and not is_instructor(course)
): ):
return { return {
@@ -1336,12 +1347,12 @@ def get_neighbour_lesson(course, chapter, lesson):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@rate_limit(limit=50, seconds=60 * 60) @rate_limit(limit=500, seconds=60 * 60)
def get_batch_details(batch): def get_batch_details(batch):
batch_students = frappe.get_all("LMS Batch Enrollment", {"batch": batch}, pluck="member") batch_students = frappe.get_all("LMS Batch Enrollment", {"batch": batch}, pluck="member")
if ( if (
not frappe.db.get_value("LMS Batch", batch, "published") not frappe.db.get_value("LMS Batch", batch, "published")
and has_student_role() and not can_create_batches()
and frappe.session.user not in batch_students and frappe.session.user not in batch_students
): ):
return return
@@ -1457,7 +1468,7 @@ def get_question_details(question):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@rate_limit(limit=50, seconds=60 * 60) @rate_limit(limit=500, seconds=60 * 60)
def get_batch_courses(batch): def get_batch_courses(batch):
courses = [] courses = []
course_list = frappe.get_all("Batch Course", {"parent": batch}, ["name", "course"]) course_list = frappe.get_all("Batch Course", {"parent": batch}, ["name", "course"])
@@ -1681,6 +1692,11 @@ def has_submitted_assessment(assessment, assessment_type, member=None):
docfield = "quiz" docfield = "quiz"
fields = ["percentage"] fields = ["percentage"]
not_attempted = 0 not_attempted = 0
elif assessment_type == "LMS Programming Exercise":
doctype = "LMS Programming Exercise Submission"
docfield = "exercise"
fields = ["status"]
not_attempted = "Not Attempted"
filters = {} filters = {}
filters[docfield] = assessment filters[docfield] = assessment
@@ -1944,9 +1960,9 @@ def get_lesson_creation_details(course, chapter, lesson):
def get_roles(name): def get_roles(name):
frappe.only_for("Moderator") frappe.only_for("Moderator")
return { return {
"moderator": has_course_moderator_role(name), "moderator": has_moderator_role(name),
"course_creator": has_course_instructor_role(name), "course_creator": has_course_instructor_role(name),
"batch_evaluator": has_course_evaluator_role(name), "batch_evaluator": has_evaluator_role(name),
"lms_student": has_student_role(name), "lms_student": has_student_role(name),
} }
@@ -2048,12 +2064,43 @@ def enroll_in_course(course, payment_name):
@frappe.whitelist() @frappe.whitelist()
def enroll_in_batch(batch, payment_name=None): def enroll_in_batch(batch, payment_name=None):
if not frappe.db.exists("LMS Batch Enrollment", {"batch": batch, "member": frappe.session.user}): if not frappe.db.exists("LMS Batch", batch):
batch_doc = frappe.db.get_value("LMS Batch", batch, ["name", "seat_count"], as_dict=True) frappe.throw(_("The specified batch does not exist."))
students = frappe.db.count("LMS Batch Enrollment", {"batch": batch})
if batch_doc.seat_count and students >= batch_doc.seat_count:
frappe.throw(_("The batch is full. Please contact the Administrator."))
batch_doc = frappe.db.get_value(
"LMS Batch", batch, ["name", "seat_count", "allow_self_enrollment"], as_dict=True
)
payment_doc = get_payment_details(payment_name)
validate_enrollment_eligibility(batch_doc, payment_doc)
create_enrollment(batch, payment_doc)
def get_payment_details(payment_name):
payment_doc = None
if payment_name:
payment_doc = frappe.db.get_value(
"LMS Payment", payment_name, ["name", "source", "payment_received"], as_dict=True
)
return payment_doc
def validate_enrollment_eligibility(batch_doc, payment_doc=None):
if frappe.db.exists("LMS Batch Enrollment", {"batch": batch_doc.name, "member": frappe.session.user}):
frappe.throw(_("You are already enrolled in this batch."))
if batch_doc.paid_batch:
if not payment_doc or not payment_doc.payment_received:
frappe.throw(_("Payment is required to enroll in this batch."))
elif not batch_doc.allow_self_enrollment:
frappe.throw(_("Enrollment in this batch is restricted. Please contact the Administrator."))
students = frappe.db.count("LMS Batch Enrollment", {"batch": batch_doc.name})
if batch_doc.seat_count and students >= batch_doc.seat_count:
frappe.throw(_("There are no seats available in this batch."))
def create_enrollment(batch, payment_doc=None):
new_student = frappe.new_doc("LMS Batch Enrollment") new_student = frappe.new_doc("LMS Batch Enrollment")
new_student.update( new_student.update(
{ {
@@ -2062,12 +2109,11 @@ def enroll_in_batch(batch, payment_name=None):
} }
) )
if payment_name: if payment_doc:
payment = frappe.db.get_value("LMS Payment", payment_name, ["name", "source"], as_dict=True)
new_student.update( new_student.update(
{ {
"payment": payment.name, "payment": payment_doc.name,
"source": payment.source, "source": payment_doc.source,
} }
) )
new_student.save() new_student.save()
@@ -2159,8 +2205,8 @@ def get_program_details(program_name):
@frappe.whitelist() @frappe.whitelist()
def enroll_in_program(program): def enroll_in_program(program):
if frappe.session.user == "Guest": validate_program_enrollment(program)
frappe.throw(_("Please login to enroll in the program."))
if not frappe.db.exists("LMS Program Member", {"parent": program, "member": frappe.session.user}): if not frappe.db.exists("LMS Program Member", {"parent": program, "member": frappe.session.user}):
program_member = frappe.new_doc("LMS Program Member") program_member = frappe.new_doc("LMS Program Member")
program_member.update( program_member.update(
@@ -2174,8 +2220,17 @@ def enroll_in_program(program):
program_member.save(ignore_permissions=True) program_member.save(ignore_permissions=True)
def validate_program_enrollment(program):
if frappe.session.user == "Guest":
frappe.throw(_("Please login to enroll in the program."))
published = frappe.db.get_value("LMS Program", program, "published")
if not published:
frappe.throw(_("You cannot enroll in an unpublished program."))
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@rate_limit(limit=50, seconds=60 * 60) @rate_limit(limit=500, seconds=60 * 60)
def get_batches(filters=None, start=0, order_by="start_date"): def get_batches(filters=None, start=0, order_by="start_date"):
if not filters: if not filters:
filters = {} filters = {}
@@ -2289,7 +2344,7 @@ def get_palette(full_name):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@rate_limit(limit=50, seconds=60 * 60) @rate_limit(limit=500, seconds=60 * 60)
def get_related_courses(course): def get_related_courses(course):
related_course_details = [] related_course_details = []
related_courses = frappe.get_all("Related Courses", {"parent": course}, order_by="idx", pluck="course") related_courses = frappe.get_all("Related Courses", {"parent": course}, order_by="idx", pluck="course")
@@ -2638,3 +2693,48 @@ def get_streak_info():
"current_streak": current_streak, "current_streak": current_streak,
"longest_streak": longest_streak, "longest_streak": longest_streak,
} }
def validate_discussion_reply(doc, method):
topic = frappe.db.get_value(
"Discussion Topic", doc.topic, ["reference_doctype", "reference_docname"], as_dict=True
)
if topic.reference_doctype == "Course Lesson":
validate_course_access(topic.reference_docname)
elif topic.reference_doctype == "LMS Batch":
validate_batch_access(topic.reference_docname)
def validate_course_access(lesson):
if not frappe.db.exists("Course Lesson", lesson):
frappe.throw(_("The lesson does not exist."))
if has_moderator_role():
return
if has_course_instructor_role():
return
course = frappe.db.get_value("Course Lesson", lesson, "course")
enrollment_exists = frappe.db.exists("LMS Enrollment", {"member": frappe.session.user, "course": course})
if not enrollment_exists:
frappe.throw(_("You do not have access to this course."))
def validate_batch_access(batch):
if not frappe.db.exists("LMS Batch", batch):
frappe.throw(_("The batch does not exist."))
if has_moderator_role():
return
if has_evaluator_role():
return
enrollment_exists = frappe.db.exists(
"LMS Batch Enrollment", {"member": frappe.session.user, "batch": batch}
)
if not enrollment_exists:
frappe.throw(_("You do not have access to this batch."))
+7 -3
View File
@@ -1,6 +1,10 @@
{ {
"app": "lms", "app": "lms",
"charts": [ "charts": [
{
"chart_name": "Certification",
"label": "Certification"
},
{ {
"chart_name": "New Signups", "chart_name": "New Signups",
"label": "Signups" "label": "Signups"
@@ -10,7 +14,7 @@
"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=\\\"/lms/courses\\\">Visit LMS Portal</a>\",\"col\":4}},{\"id\":\"lGMuNLpmv-\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"/lms/courses/new/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 Settings</a>\",\"col\":4}},{\"id\":\"0ATmnKmXjc\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"https://docs.frappe.io/learning\\\">Documentation</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=\\\"/lms/courses/new/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 Settings</a>\",\"col\":4}},{\"id\":\"0ATmnKmXjc\",\"type\":\"paragraph\",\"data\":{\"text\":\"<a href=\\\"https://docs.frappe.io/learning\\\">Documentation</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\":\"_HkvT3xKVi\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Certification\",\"col\":12}},{\"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": [], "custom_blocks": [],
"docstatus": 0, "docstatus": 0,
@@ -146,8 +150,8 @@
"type": "Link" "type": "Link"
} }
], ],
"modified": "2024-11-21 12:16:25.886431", "modified": "2025-12-08 13:23:09.718683",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS", "name": "LMS",
"number_cards": [], "number_cards": [],
+521 -267
View File
File diff suppressed because it is too large Load Diff
+514 -260
View File
File diff suppressed because it is too large Load Diff
+500 -246
View File
File diff suppressed because it is too large Load Diff
+501 -247
View File
File diff suppressed because it is too large Load Diff
+502 -248
View File
File diff suppressed because it is too large Load Diff
+501 -247
View File
File diff suppressed because it is too large Load Diff
+525 -271
View File
File diff suppressed because it is too large Load Diff
+523 -269
View File
File diff suppressed because it is too large Load Diff
+519 -265
View File
File diff suppressed because it is too large Load Diff
+593 -339
View File
File diff suppressed because it is too large Load Diff
+516 -262
View File
File diff suppressed because it is too large Load Diff
+501 -247
View File
File diff suppressed because it is too large Load Diff
+503 -249
View File
File diff suppressed because it is too large Load Diff
+629 -281
View File
File diff suppressed because it is too large Load Diff
+500 -246
View File
File diff suppressed because it is too large Load Diff
+518 -264
View File
File diff suppressed because it is too large Load Diff
+500 -246
View File
File diff suppressed because it is too large Load Diff
+501 -247
View File
File diff suppressed because it is too large Load Diff
+500 -246
View File
File diff suppressed because it is too large Load Diff
+501 -247
View File
File diff suppressed because it is too large Load Diff
+1260 -1006
View File
File diff suppressed because it is too large Load Diff
+8099
View File
File diff suppressed because it is too large Load Diff
+501 -247
View File
File diff suppressed because it is too large Load Diff
+501 -247
View File
File diff suppressed because it is too large Load Diff
+505 -251
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More