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

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

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

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

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

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']

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

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>

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>

View File

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

View File

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

View File

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

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]})` */

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>

View File

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

View File

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

View File

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

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,15 +16,17 @@
}" }"
> >
<template #body-content> <template #body-content>
<Link <div class="text-base">
v-model="page.webpage" <Link
doctype="Web Page" v-model="page.webpage"
:label="__('Web Page')" doctype="Web Page"
:filters="{ :label="__('Web Page')"
published: 1, :filters="{
}" published: 1,
/> }"
<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>

View File

@@ -117,32 +117,27 @@ 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.name = acc.name account.member = user?.data?.name || ''
account.enabled = acc.enabled || false account.account_id = ''
account.member = acc.member account.client_id = ''
account.account_id = acc.account_id account.client_secret = ''
account.client_id = acc.client_id } else if (val && val !== 'new') {
account.client_secret = acc.client_secret const acc = zoomAccounts.value?.data.find((acc) => acc.name === val)
} if (acc) {
}) account.name = acc.name
account.enabled = acc.enabled || false
account.member = acc.member
account.account_id = acc.account_id
account.client_id = acc.client_id
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)

View File

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

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>

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,

View File

@@ -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, sidebarSettings.reload()
} toast.success(__('Page deleted successfully'))
}, })
}).submit(
{},
{
onSuccess() {
sidebarSettings.reload()
},
}
)
} }
const toggleSidebar = () => { const toggleSidebar = () => {

View File

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

View File

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

View File

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

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

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

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

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="[
name: 'CourseForm', {
params: { courseName: 'new' }, label: __('New Course'),
}" icon: 'book-open',
onClick() {
router.push({
name: 'CourseForm',
params: { courseName: 'new' },
})
},
},
{
label: __('Import Course'),
icon: 'upload',
onClick() {
router.push({
name: 'NewDataImport',
params: { doctype: 'LMS Course' },
})
},
},
]"
> >
<Button variant="solid"> <template v-slot="{ open }">
<template #prefix> <Button variant="solid">
<Plus class="h-4 w-4 stroke-1.5" /> <template #prefix>
</template> <Plus class="h-4 w-4 stroke-1.5" />
{{ __('Create') }} </template>
</Button> {{ __('Create') }}
</router-link> <template #suffix>
<ChevronDown
:class="[
'w-4 h-4 stroke-1.5 ml-1 transform transition-transform',
open ? 'rotate-180' : '',
]"
/>
</template>
</Button>
</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'

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>

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

View File

@@ -17,86 +17,91 @@
</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>
<ListView <div v-if="applications.data?.length">
v-if="applications.data?.length" <ListView
:columns="applicationColumns" :columns="applicationColumns"
:rows="applicantRows" :rows="applicantRows"
row-key="name" row-key="name"
:options="{ :options="{
showTooltip: false, showTooltip: false,
selectable: false, selectable: false,
}" }"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
> >
<ListHeaderItem <ListHeader
:item="item" class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
v-for="item in applicationColumns"
:key="item.key"
> >
<template #prefix="{ item }"> <ListHeaderItem
<FeatherIcon :item="item"
v-if="item.icon" v-for="item in applicationColumns"
:name="item.icon?.toString()" :key="item.key"
class="h-4 w-4" >
/> <template #prefix="{ item }">
</template> <FeatherIcon
</ListHeaderItem> v-if="item.icon"
</ListHeader> :name="item.icon?.toString()"
<ListRows> class="h-4 w-4"
<ListRow
:row="row"
v-slot="{ column, item }"
v-for="row in applicantRows"
class="cursor-pointer"
>
<ListRowItem :item="item">
<div
v-if="column.key === 'full_name'"
class="flex items-center space-x-3"
>
<Avatar
size="sm"
:image="row['user_image']"
:label="row['full_name']"
/> />
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-slot="{ column, item }"
v-for="row in applicantRows"
class="cursor-pointer"
>
<ListRowItem :item="item">
<div
v-if="column.key === 'full_name'"
class="flex items-center space-x-3"
>
<Avatar
size="sm"
:image="row['user_image']"
:label="row['full_name']"
/>
<span>{{ item }}</span> <span>{{ item }}</span>
</div> </div>
<div <div
v-else-if="column.key === 'actions'" v-else-if="column.key === 'actions'"
class="flex justify-center" class="flex justify-center"
> >
<Dropdown :options="getActionOptions(row)"> <Dropdown :options="getActionOptions(row)">
<Button variant="ghost"> <Button variant="ghost">
<FeatherIcon name="more-horizontal" class="w-4 h-4" /> <FeatherIcon name="more-horizontal" class="w-4 h-4" />
</Button> </Button>
</Dropdown> </Dropdown>
</div> </div>
<div <div
v-else-if="column.key === 'applied_on'" v-else-if="column.key === 'applied_on'"
class="text-sm text-ink-gray-6" class="text-sm text-ink-gray-6"
> >
{{ item }} {{ item }}
</div> </div>
<div v-else> <div v-else>
{{ item }} {{ item }}
</div> </div>
</ListRowItem> </ListRowItem>
</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')
} }

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

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

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

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',
parentfield: 'program_courses',
course: course.value,
idx: programCourses.data.length + 1,
},
{
onSuccess() {
updateCounts('course', 'add')
close()
toast.success(__('Course added to program successfully'))
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
},
}
) )
if (!existingCourse) {
program.value.program_courses.push({
course: course.value,
idx: program.value.program_courses.length + 1,
})
if (props.programName !== 'new') {
dirty.value = true
}
close()
toast.success(__('Course added to program successfully'))
} else {
toast.warning(__('Course already added to program'))
}
} }
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',
parentfield: 'program_members',
member: member.value,
},
{
onSuccess() {
updateCounts('member', 'add')
close()
toast.success(__('Member added to program successfully'))
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
},
}
) )
if (!existingMember) {
program.value.program_members.push({
member: member.value,
})
if (props.programName !== 'new') {
dirty.value = true
}
close()
toast.success(__('Member added to program successfully'))
} else {
toast.warning(__('Member already added to program'))
}
} }
const updateCounts = async ( const updateCounts = async (
@@ -509,57 +505,83 @@ 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
let courses = programCourses.data
courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
for (const [index, course] of courses.entries()) { if (props.programName === 'new') {
programCourses.setValue.submit( let courses = program.value.program_courses
{ courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
name: course.name, courses.forEach((course, index) => {
idx: index + 1, course.idx = index + 1
}, })
{ dirty.value = true
onError(err: any) { } else {
toast.warning(__(err.messages?.[0] || err)) let courses = programCourses.data
courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
for (const [index, course] of courses.entries()) {
programCourses.setValue.submit(
{
name: course.name,
idx: index + 1,
}, },
} {
) onError(err: any) {
await wait(100) toast.warning(__(err.messages?.[0] || err))
},
}
)
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
programs.value?.delete.submit(props.programName, { $dialog({
onSuccess() { title: __('Delete Program'),
toast.success(__('Program deleted successfully')) message: __(
close() 'Are you sure you want to delete this program? This action cannot be undone.'
}, ),
onError(err: any) { actions: [
toast.warning(__(err.messages?.[0] || err)) {
}, label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick(closeDialog) {
programs.value?.delete.submit(props.programName, {
onSuccess() {
toast.success(__('Program deleted successfully'))
close()
closeDialog()
},
onError(err: any) {
toast.warning(__(err.messages?.[0] || err))
closeDialog()
},
})
},
},
],
}) })
} }
@@ -567,7 +589,7 @@ const courseColumns = computed(() => {
return [ return [
{ {
label: 'Title', label: 'Title',
key: 'course_title', key: props.programName === 'new' ? 'course' : 'course_title',
width: 1, width: 1,
}, },
] ]

View File

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

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

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

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

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

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)
this.api.caret.focus(true)
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)
}
} 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())
} }
} }

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

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
__version__ = "2.40.0" __version__ = "2.41.0"

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

View File

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

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,18 +1500,43 @@ 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:
route_meta = frappe.new_doc("Website Route Meta") create_meta(parent_name, tag_properties)
route_meta.update(
{
"__newname": parent_name,
}
)
route_meta.append("meta_tags", tag_properties)
route_meta.insert()
else: else:
new_tag = frappe.new_doc("Website Meta Tag") create_meta_tag(tag_properties)
new_tag.update(tag_properties)
new_tag.insert()
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.update(
{
"__newname": parent_name,
}
)
route_meta.append("meta_tags", tag_properties)
route_meta.insert()
def create_meta_tag(tag_properties):
new_tag = frappe.new_doc("Website Meta Tag")
new_tag.update(tag_properties)
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()
@@ -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,
) )

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

@@ -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,18 +105,14 @@
}, },
{ {
"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": [],
"title_field": "member_name" "title_field": "member_name"
} }

View File

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

View File

@@ -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,9 +74,10 @@
"share": 1 "share": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"title_field": "category", "title_field": "category",
"track_changes": 1 "track_changes": 1
} }

View File

@@ -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,29 +115,14 @@ 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:
default_certificate_template = frappe.db.get_value( validate_certification_eligibility(course)
"Property Setter", default_certificate_template = get_default_certificate_template()
{
"doc_type": "LMS Certificate",
"property": "default_print_format",
},
"value",
)
if not default_certificate_template:
default_certificate_template = frappe.db.get_value(
"Print Format",
{
"doc_type": "LMS Certificate",
},
)
certificate = frappe.get_doc( certificate = frappe.get_doc(
{ {
"doctype": "LMS Certificate", "doctype": "LMS Certificate",
@@ -149,3 +134,37 @@ def create_certificate(course):
) )
certificate.save(ignore_permissions=True) certificate.save(ignore_permissions=True)
return certificate return certificate
def get_default_certificate_template():
default_certificate_template = frappe.db.get_value(
"Property Setter",
{
"doc_type": "LMS Certificate",
"property": "default_print_format",
},
"value",
)
if not default_certificate_template:
default_certificate_template = frappe.db.get_value(
"Print Format",
{
"doc_type": "LMS Certificate",
},
)
return default_certificate_template
def validate_certification_eligibility(course):
if not frappe.db.exists("LMS Enrollment", {"course": course, "member": frappe.session.user}):
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"
)
if progress < 100:
frappe.throw(_("You have not completed the course yet."))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

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,29 +2064,59 @@ 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."))
new_student = frappe.new_doc("LMS Batch Enrollment") 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.update(
{
"member": frappe.session.user,
"batch": batch,
}
)
if payment_doc:
new_student.update( new_student.update(
{ {
"member": frappe.session.user, "payment": payment_doc.name,
"batch": batch, "source": payment_doc.source,
} }
) )
new_student.save()
if payment_name:
payment = frappe.db.get_value("LMS Payment", payment_name, ["name", "source"], as_dict=True)
new_student.update(
{
"payment": payment.name,
"source": payment.source,
}
)
new_student.save()
def update_certificate_purchase(course, payment_name): def update_certificate_purchase(course, payment_name):
@@ -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."))

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": [],
@@ -215,4 +219,4 @@
], ],
"title": "LMS", "title": "LMS",
"type": "Workspace" "type": "Workspace"
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

8099
lms/locale/sl.po Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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