Merge pull request #2108 from pateljannat/issues-185

fix: misc permission issues
This commit is contained in:
Jannat Patel
2026-02-23 11:22:52 +05:30
committed by GitHub
45 changed files with 541 additions and 480 deletions
@@ -107,7 +107,7 @@
<div class="flex flex-col gap-1 p-1"> <div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8"> <div class="text-base font-medium text-ink-gray-8">
{{ {{
option.value == option.label option.value == option.label && option.description
? option.description ? option.description
: option.label : option.label
}} }}
@@ -124,7 +124,7 @@
v-if="groups.length == 0" v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5" class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
> >
No results found {{ __('No results found') }}
</li> </li>
</ComboboxOptions> </ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5"> <div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
@@ -38,7 +38,7 @@
'border object-cover', 'border object-cover',
shape === 'circle' shape === 'circle'
? 'w-20 h-20 rounded-full' ? 'w-20 h-20 rounded-full'
: 'w-44 h-auto min-h-20 rounded-md', : 'w-44 h-auto min-h-20 max-h-32 rounded-md',
]" ]"
/> />
<video v-else controls class="border rounded-md w-44 h-auto"> <video v-else controls class="border rounded-md w-44 h-auto">
+4 -4
View File
@@ -12,7 +12,7 @@
</div> </div>
<div class="grid gap-8 mt-10"> <div class="grid gap-8 mt-10">
<div v-for="(review, index) in reviews.data"> <div v-for="(review, index) in reviews.data">
<div class="flex items-center"> <div class="flex">
<router-link <router-link
:to="{ :to="{
name: 'Profile', name: 'Profile',
@@ -46,11 +46,11 @@
" "
/> />
</div> </div>
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
{{ review.review }}
</div>
</div> </div>
</div> </div>
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
{{ review.review }}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -3,7 +3,7 @@
v-model="show" v-model="show"
:options="{ :options="{
size: '4xl', size: '4xl',
title: __('Video Statistics for {0}').format(lessonTitle), title: __('Video Statistics'),
}" }"
> >
<template #body-content> <template #body-content>
@@ -21,17 +21,22 @@
class="mt-2 mr-5 w-[25%]" class="mt-2 mr-5 w-[25%]"
/> --> /> -->
</div> </div>
<div v-if="currentTab" class="mt-4"> <div
v-if="currentTab"
:class="{
'mt-5': tabs.length > 1,
}"
>
<div class="grid grid-cols-[55%,40%] gap-5"> <div class="grid grid-cols-[55%,40%] gap-5">
<div <div
class="space-y-5 border rounded-md p-2 pt-4 h-[70vh] overflow-y-auto" class="space-y-5 border rounded-md p-2 pt-4 max-h-[70vh] overflow-y-auto"
> >
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5"> <div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
<div class="px-4"> <div class="px-4">
{{ __('Member') }} {{ __('Member') }}
</div> </div>
<div class="text-center"> <div class="text-center">
{{ __('Watch Time') }} {{ __('Watch Time (mins)') }}
</div> </div>
</div> </div>
<div <div
@@ -68,15 +73,16 @@
</div> </div>
</div> </div>
<div class="space-y-5"> <div class="space-y-5">
<NumberChart <NumberChartGraph
class="border rounded-md" :title="__('Average Watch Time (mins)')"
:config="{ :value="averageWatchTime"
title: __('Average Watch Time'),
value: averageWatchTime,
}"
/> />
<div v-if="isPlyrSource"> <div v-if="isPlyrSource">
<div class="video-player" :src="currentTab"></div> <div
class="video-player"
:data-plyr-provider="provider"
:src="currentTab"
></div>
</div> </div>
<VideoBlock v-else :file="currentTab" /> <VideoBlock v-else :file="currentTab" />
</div> </div>
@@ -101,6 +107,7 @@ import {
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { enablePlyr, formatTimestamp } from '@/utils' import { enablePlyr, formatTimestamp } from '@/utils'
import VideoBlock from '@/components/VideoBlock.vue' import VideoBlock from '@/components/VideoBlock.vue'
import NumberChartGraph from '@/components/NumberChartGraph.vue'
const show = defineModel<boolean | undefined>() const show = defineModel<boolean | undefined>()
const currentTab = ref<string>('') const currentTab = ref<string>('')
@@ -171,7 +178,7 @@ watch(show, () => {
const statisticsData = computed(() => { const statisticsData = computed(() => {
const grouped = <Record<string, any[]>>{} const grouped = <Record<string, any[]>>{}
statistics.data.forEach((item: { source: string }) => { statistics.data?.forEach((item: { source: string }) => {
if (!grouped[item.source]) { if (!grouped[item.source]) {
grouped[item.source] = [] grouped[item.source] = []
} }
@@ -206,7 +206,7 @@ const referenceDoctypeOptions = computed(() => {
}) })
const eventOptions = computed(() => { const eventOptions = computed(() => {
let options = ['New', 'Value Change', 'Auto Assign'] let options = ['New', 'Value Change', 'Manual Assignment']
return options.map((event) => ({ label: __(event), value: event })) return options.map((event) => ({ label: __(event), value: event }))
}) })
@@ -6,16 +6,18 @@
<div class="text-xl font-semibold leading-none text-ink-gray-9"> <div class="text-xl font-semibold leading-none text-ink-gray-9">
{{ __(label) }} {{ __(label) }}
</div> </div>
</div>
<div class="space-x-2">
<Badge <Badge
v-if="data.isDirty" v-if="data.isDirty"
:label="__('Not Saved')" :label="__('Not Saved')"
variant="subtle" variant="subtle"
theme="orange" theme="orange"
/> />
<Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }}
</Button>
</div> </div>
<Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }}
</Button>
</div> </div>
<div class="text-ink-gray-6 leading-5"> <div class="text-ink-gray-6 leading-5">
{{ __(description) }} {{ __(description) }}
@@ -219,6 +219,25 @@ const tabsStructure = computed(() => {
}, },
], ],
}, },
{
label: 'Jobs',
columns: [
{
fields: [
{
label: 'Allow Job Posting',
name: 'allow_job_posting',
type: 'checkbox',
description:
'If enabled, users can post job openings on the job board. Else only admins can post jobs.',
},
],
},
{
fields: [],
},
],
},
{ {
label: '', label: '',
columns: [ columns: [
@@ -31,12 +31,14 @@
<div v-if="upcoming_evals.data?.length"> <div v-if="upcoming_evals.data?.length">
<div <div
class="grid gap-4" class="grid gap-4"
:class="forHome ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-1'" :class="forHome ? 'grid-cols-1 md:grid-cols-4' : 'grid-cols-1'"
> >
<div v-for="evl in upcoming_evals.data"> <div v-for="evl in upcoming_evals.data">
<div class="border text-ink-gray-7 rounded-md p-3"> <div
class="border hover:border-outline-gray-3 text-ink-gray-7 rounded-md p-3"
>
<div class="flex justify-between mb-3"> <div class="flex justify-between mb-3">
<span class="text-lg font-semibold text-ink-gray-9 leading-5"> <span class="font-semibold text-ink-gray-9 leading-5">
{{ evl.course_title }} {{ evl.course_title }}
</span> </span>
<Menu <Menu
-82
View File
@@ -1,82 +0,0 @@
<template>
<div v-if="badge.data">
<div class="p-5 flex flex-col items-center mt-40">
<div class="text-3xl font-semibold">
{{ badge.data.badge }}
</div>
<img
:src="badge.data.badge_image"
:alt="badge.data.badge"
class="h-60 mt-2"
/>
<div class="">
{{
__('This badge has been awarded to {0} on {1}.').format(
badge.data.member_name,
dayjs(badge.data.issued_on).format('DD MMM YYYY')
)
}}
</div>
<div class="mt-2">
{{ badge.data.badge_description }}
</div>
</div>
</div>
</template>
<script setup>
import { createResource, usePageMeta } from 'frappe-ui'
import { computed, inject } from 'vue'
import { sessionStore } from '../stores/session'
const dayjs = inject('$dayjs')
const { brand } = sessionStore()
const props = defineProps({
badgeName: {
type: String,
required: true,
},
email: {
type: String,
required: true,
},
})
const badge = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Badge Assignment',
filters: {
badge: props.badgeName,
member: props.email,
},
}
},
auto: true,
})
const breadcrumbs = computed(() => {
return [
{
label: __('Badges'),
},
{
label: badge.data.badge,
route: {
name: 'Badge',
params: {
badge: badge.data.badge,
},
},
},
]
})
usePageMeta(() => {
return {
title: badge.data.badge,
icon: brand.favicon,
}
})
</script>
@@ -43,7 +43,7 @@
} }
" "
> >
<div class="font-semibold text-ink-gray-9 text-lg mb-1"> <div class="font-semibold text-ink-gray-9 mb-1">
{{ cls.title }} {{ cls.title }}
</div> </div>
<div class="short-introduction"> <div class="short-introduction">
+4 -4
View File
@@ -1,17 +1,17 @@
<template> <template>
<div> <div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-5 mt-10"> <div class="mt-10 space-y-10">
<UpcomingEvaluations :forHome="true" /> <UpcomingEvaluations :forHome="true" />
<div v-if="myLiveClasses.data?.length"> <div v-if="myLiveClasses.data?.length">
<div class="font-semibold text-lg mb-3 text-ink-gray-9"> <div class="font-semibold text-lg mb-3 text-ink-gray-9">
{{ __('Upcoming Live Classes') }} {{ __('Upcoming Live Classes') }}
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5"> <div class="grid grid-cols-1 md:grid-cols-4 gap-5">
<div <div
v-for="cls in myLiveClasses.data" v-for="cls in myLiveClasses.data"
class="border rounded-md hover:border-outline-gray-3 p-2" class="border rounded-md hover:border-outline-gray-3 p-3"
> >
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1"> <div class="font-semibold text-ink-gray-9 leading-5 mb-1">
{{ cls.title }} {{ cls.title }}
</div> </div>
<div class="text-ink-gray-5 leading-5 mb-4"> <div class="text-ink-gray-5 leading-5 mb-4">
+127 -107
View File
@@ -4,9 +4,14 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5" class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<Button variant="solid" @click="saveJob()"> <div class="space-x-2">
{{ __('Save') }} <Badge v-if="isDirty" theme="orange">
</Button> {{ __('Not Saved') }}
</Badge>
<Button variant="solid" @click="saveJob()">
{{ __('Save') }}
</Button>
</div>
</header> </header>
<div class="py-5"> <div class="py-5">
<div class="container border-b mb-4 pb-5"> <div class="container border-b mb-4 pb-5">
@@ -109,15 +114,25 @@
</template> </template>
<script setup> <script setup>
import { import {
Badge,
Breadcrumbs, Breadcrumbs,
call,
FormControl, FormControl,
createResource, createDocumentResource,
Button, Button,
TextEditor, TextEditor,
usePageMeta, usePageMeta,
toast, toast,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, onMounted, reactive, inject } from 'vue' import {
computed,
inject,
onMounted,
onBeforeUnmount,
reactive,
ref,
watch,
} from 'vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { escapeHTML, sanitizeHTML } from '@/utils' import { escapeHTML, sanitizeHTML } from '@/utils'
@@ -126,6 +141,8 @@ import Uploader from '@/components/Controls/Uploader.vue'
const user = inject('$user') const user = inject('$user')
const router = useRouter() const router = useRouter()
const { brand } = sessionStore() const { brand } = sessionStore()
const isDirty = ref(false)
const originalJobData = ref(null)
const props = defineProps({ const props = defineProps({
jobName: { jobName: {
@@ -134,67 +151,6 @@ const props = defineProps({
}, },
}) })
const newJob = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Job Opportunity',
company_logo: job.company_logo,
...job,
},
}
},
})
const updateJob = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Job Opportunity',
name: props.jobName,
fieldname: {
company_logo: job.company_logo,
...job,
},
}
},
})
const jobDetail = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'Job Opportunity',
name: props.jobName,
}
},
onSuccess(data) {
if (data.owner != user.data?.name && !user.data?.is_moderator) {
router.push({
name: 'Jobs',
})
}
Object.keys(data).forEach((key) => {
if (Object.hasOwn(job, key)) job[key] = data[key]
})
},
})
const job = reactive({
job_title: '',
location: '',
country: '',
type: 'Full Time',
work_mode: 'On-site',
status: 'Open',
company_name: '',
company_website: '',
company_logo: null,
description: '',
company_email_address: '',
})
onMounted(() => { onMounted(() => {
if (!user.data) { if (!user.data) {
router.push({ router.push({
@@ -202,22 +158,64 @@ onMounted(() => {
}) })
} }
if (props.jobName != 'new') jobDetail.reload() if (props.jobName != 'new') jobDetails.reload()
addKeyboardShortcuts() window.addEventListener('keydown', keyboardShortcut)
}) })
const addKeyboardShortcuts = () => { const job = reactive({
document.addEventListener('keydown', (e) => { job_title: '',
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { type: '',
e.preventDefault() work_mode: '',
saveJob() location: '',
country: '',
status: 'Open',
description: '',
company_name: '',
company_website: '',
company_email_address: '',
company_logo: '',
})
const jobDetails = createDocumentResource({
doctype: 'Job Opportunity',
name: props.jobName != 'new' ? props.jobName : undefined,
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
auto: props.jobName != 'new',
})
watch(
() => jobDetails?.doc,
() => {
if (!jobDetails.doc) return
if (jobDetails.doc.owner != user.data?.name && !user.data?.is_moderator) {
router.push({
name: 'Jobs',
})
} }
})
} if (jobDetails.doc) {
Object.assign(job, jobDetails.doc)
originalJobData.value = JSON.parse(JSON.stringify(jobDetails.doc))
}
}
)
watch(
job,
() => {
isDirty.value = Object.keys(job).some((key) => {
return job[key] != originalJobData.value?.[key]
})
},
{ deep: true }
)
const saveJob = () => { const saveJob = () => {
validateJobFields() validateJobFields()
if (jobDetail.data) { if (jobDetails?.doc) {
editJobDetails() editJobDetails()
} else { } else {
createNewJob() createNewJob()
@@ -225,38 +223,46 @@ const saveJob = () => {
} }
const createNewJob = () => { const createNewJob = () => {
newJob.submit( call('frappe.client.insert', {
{}, doc: {
{ doctype: 'Job Opportunity',
onSuccess(data) { company_logo: job.company_logo,
router.push({ ...job,
name: 'JobDetail', },
params: { })
job: data.name, .then((data) => {
}, router.push({
}) name: 'JobDetail',
}, params: {
onError(err) { job: data.name,
toast.error(err.messages?.[0] || err) },
}, })
} })
) .catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
} }
const editJobDetails = () => { const editJobDetails = () => {
updateJob.submit( jobDetails.setValue.submit(
{}, {
company_logo: job.company_logo,
...job,
},
{ {
onSuccess(data) { onSuccess(data) {
jobDetails.reload()
router.push({ router.push({
name: 'JobDetail', name: 'JobDetail',
params: { params: {
job: data.name, job: props.jobName,
}, },
}) })
}, },
onError(err) { onError(err) {
toast.error(err.messages?.[0] || err) toast.error(err.messages?.[0] || err)
console.error(err)
}, },
} }
) )
@@ -271,27 +277,38 @@ const validateJobFields = () => {
}) })
} }
const keyboardShortcut = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
saveJob()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const jobTypes = computed(() => { const jobTypes = computed(() => {
return [ return [
{ 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' },
{ label: 'Freelance', value: 'Freelance' }, { label: __('Freelance'), value: 'Freelance' },
] ]
}) })
const workModes = computed(() => { const workModes = computed(() => {
return [ return [
{ 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' },
] ]
}) })
const jobStatuses = computed(() => { const jobStatuses = computed(() => {
return [ return [
{ label: 'Open', value: 'Open' }, { label: __('Open'), value: 'Open' },
{ label: 'Closed', value: 'Closed' }, { label: __('Closed'), value: 'Closed' },
] ]
}) })
@@ -302,8 +319,11 @@ const breadcrumbs = computed(() => {
route: { name: 'Jobs' }, route: { name: 'Jobs' },
}, },
{ {
label: props.jobName == 'new' ? __('New Job') : __('Edit Job'), label: props.jobName == 'new' ? __('New Job') : jobDetails.doc?.job_title,
route: { name: 'JobForm' }, route:
props.jobName == 'new'
? {}
: { name: 'JobDetail', params: { job: props.jobName } },
}, },
] ]
return crumbs return crumbs
@@ -311,7 +331,7 @@ const breadcrumbs = computed(() => {
usePageMeta(() => { usePageMeta(() => {
return { return {
title: props.jobName == 'new' ? __('New Job') : jobDetail.data?.job_title, title: props.jobName == 'new' ? __('New Job') : jobDetails.doc?.job_title,
icon: brand.favicon, icon: brand.favicon,
} }
}) })
+7 -3
View File
@@ -8,7 +8,9 @@
:items="[{ label: __('Jobs'), route: { name: 'Jobs' } }]" :items="[{ label: __('Jobs'), route: { name: 'Jobs' } }]"
/> />
<router-link <router-link
v-if="user.data?.name" v-if="
user.data?.name && settings.data?.allow_job_posting && !readOnlyMode
"
:to="{ :to="{
name: 'JobForm', name: 'JobForm',
params: { params: {
@@ -16,7 +18,7 @@
}, },
}" }"
> >
<Button v-if="!readOnlyMode" variant="solid"> <Button variant="solid">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
@@ -123,7 +125,8 @@ import {
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { Plus, Search } from 'lucide-vue-next' import { Plus, Search } from 'lucide-vue-next'
import { sessionStore } from '../stores/session' import { sessionStore } from '@/stores/session'
import { useSettings } from '@/stores/settings'
import { inject, computed, ref, onMounted, watch } from 'vue' import { inject, computed, ref, onMounted, watch } from 'vue'
import JobCard from '@/components/JobCard.vue' import JobCard from '@/components/JobCard.vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
@@ -133,6 +136,7 @@ const user = inject('$user')
const jobType = ref(null) const jobType = ref(null)
const workMode = ref(null) const workMode = ref(null)
const { brand } = sessionStore() const { brand } = sessionStore()
const { settings } = useSettings()
const searchQuery = ref('') const searchQuery = ref('')
const country = ref(null) const country = ref(null)
const filters = ref({}) const filters = ref({})
+18 -14
View File
@@ -12,7 +12,7 @@
</template> </template>
</Button> </Button>
</Tooltip> </Tooltip>
<Button v-if="canSeeStats()" @click="showVideoStats()"> <Button v-if="isAdmin" @click="showVideoStats()">
<template #icon> <template #icon>
<TrendingUp class="size-4 stroke-1.5" /> <TrendingUp class="size-4 stroke-1.5" />
</template> </template>
@@ -326,7 +326,7 @@
@updateNotes="updateNotes" @updateNotes="updateNotes"
/> />
<VideoStatistics <VideoStatistics
v-if="showStatsDialog" v-if="isAdmin"
v-model="showStatsDialog" v-model="showStatsDialog"
:lessonName="lesson.data?.name" :lessonName="lesson.data?.name"
:lessonTitle="lesson.data?.title" :lessonTitle="lesson.data?.title"
@@ -524,7 +524,14 @@ const renderEditor = (holder, content) => {
const markProgress = () => { const markProgress = () => {
if (user.data && lesson.data && !lesson.data.progress) { if (user.data && lesson.data && !lesson.data.progress) {
progress.submit() progress.submit(
{},
{
onError(err) {
console.error(err)
},
}
)
} }
} }
@@ -605,7 +612,6 @@ watch(
plyrSources.value = [] plyrSources.value = []
await nextTick() await nextTick()
resetLessonState(newChapterNumber, newLessonNumber) resetLessonState(newChapterNumber, newLessonNumber)
startTimer()
updateNotes() updateNotes()
checkIfDiscussionsAllowed() checkIfDiscussionsAllowed()
checkQuiz() checkQuiz()
@@ -674,6 +680,7 @@ watch(
() => lesson.data, () => lesson.data,
async (data) => { async (data) => {
setupLesson(data) setupLesson(data)
startTimer()
getPlyrSource() getPlyrSource()
updateNotes() updateNotes()
if (data.icon == 'icon-youtube') clearInterval(timerInterval) if (data.icon == 'icon-youtube') clearInterval(timerInterval)
@@ -769,17 +776,19 @@ const checkIfDiscussionsAllowed = () => {
} }
} }
const isAdmin = computed(() => {
let isInstructor = lesson.data?.instructors?.includes(user.data?.name)
return user.data?.is_moderator || isInstructor
})
const allowEdit = () => { const allowEdit = () => {
if (window.read_only_mode) return false if (window.read_only_mode) return false
if (user.data?.is_moderator) return true if (isAdmin.value) return true
if (lesson.data?.instructors?.includes(user.data?.name)) return true
return false return false
} }
const allowInstructorContent = () => { const allowInstructorContent = () => {
if (user.data?.is_moderator) return true return isAdmin.value
if (lesson.data?.instructors?.includes(user.data?.name)) return true
return false
} }
const enrollment = createResource({ const enrollment = createResource({
@@ -819,11 +828,6 @@ const toggleInlineMenu = async () => {
} }
} }
const canSeeStats = () => {
if (user.data?.is_moderator || user.data?.is_instructor) return true
return false
}
const showVideoStats = () => { const showVideoStats = () => {
showStatsDialog.value = true showStatsDialog.value = true
} }
+15 -13
View File
@@ -70,13 +70,16 @@
<div class="leading-5 mb-4"> <div class="leading-5 mb-4">
{{ badge.badge_description }} {{ badge.badge_description }}
</div> </div>
<div class="flex flex-col mb-4"> <div class="flex flex-col">
<span class="text-xs text-ink-gray-7 font-medium mb-1"> <span class="text-xs text-ink-gray-7 font-medium mb-1">
{{ __('Issued on') }}: {{ __('Issued on') }}:
</span> </span>
{{ dayjs(badge.issued_on).format('DD MMM YYYY') }} {{ dayjs(badge.issued_on).format('DD MMM YYYY') }}
</div> </div>
<div class="flex flex-col"> <div
v-if="user.data?.name == profile.data?.name"
class="flex flex-col mt-4"
>
<span class="text-xs text-ink-gray-7 font-medium mb-1"> <span class="text-xs text-ink-gray-7 font-medium mb-1">
{{ __('Share on') }}: {{ __('Share on') }}:
</span> </span>
@@ -125,6 +128,7 @@ import DOMPurify from 'dompurify'
import { getLmsRoute } from '@/utils/basePath' import { getLmsRoute } from '@/utils/basePath'
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const user = inject('$user')
const { branding } = sessionStore() const { branding } = sessionStore()
const props = defineProps({ const props = defineProps({
@@ -135,13 +139,9 @@ const props = defineProps({
}) })
const badges = createResource({ const badges = createResource({
url: 'frappe.client.get_list', url: 'lms.lms.api.get_badges',
params: { params: {
doctype: 'LMS Badge Assignment', member: props.profile.data.name,
fields: ['name', 'badge', 'badge_image', 'badge_description', 'issued_on'],
filters: {
member: props.profile.data.name,
},
}, },
auto: true, auto: true,
transform(data) { transform(data) {
@@ -160,14 +160,16 @@ const shareOnSocial = (badge, medium) => {
let shareUrl let shareUrl
const url = encodeURIComponent( const url = encodeURIComponent(
`${window.location.origin}${getLmsRoute( `${window.location.origin}${getLmsRoute(
`badges/${badge.badge}/${props.profile.data?.email}` `user/${props.profile.data?.username}`
)}` )}`
) )
const summary = `I am happy to announce that I earned the ${ const summary = __(
badge.badge 'I am happy to announce that I earned the {0} badge on {1} at {2}'
} badge on ${dayjs(badge.issued_on).format('DD MMM YYYY')} at ${ ).format(
badge.badge,
dayjs(badge.issued_on).format('DD MMM YYYY'),
branding.data?.app_name branding.data?.app_name
}.` )
if (medium == 'LinkedIn') if (medium == 'LinkedIn')
shareUrl = `https://www.linkedin.com/shareArticle?mini=true&url=${url}&text=${summary}` shareUrl = `https://www.linkedin.com/shareArticle?mini=true&url=${url}&text=${summary}`
-6
View File
@@ -139,12 +139,6 @@ const routes = [
name: 'Notifications', name: 'Notifications',
component: () => import('@/pages/Notifications.vue'), component: () => import('@/pages/Notifications.vue'),
}, },
{
path: '/badges/:badgeName/:email',
name: 'Badge',
component: () => import('@/pages/Badge.vue'),
props: true,
},
{ {
path: '/quizzes', path: '/quizzes',
name: 'Quizzes', name: 'Quizzes',
+10 -7
View File
@@ -86,13 +86,16 @@ after_migrate = [
# ----------- # -----------
# Permissions evaluated in scripted ways # Permissions evaluated in scripted ways
# permission_query_conditions = { permission_query_conditions = {
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", "LMS Certificate": "lms.lms.doctype.lms_certificate.lms_certificate.get_permission_query_conditions",
# } }
#
# has_permission = { has_permission = {
# "Event": "frappe.desk.doctype.event.event.has_permission", "LMS Live Class": "lms.lms.doctype.lms_live_class.lms_live_class.has_permission",
# } "LMS Batch": "lms.lms.doctype.lms_batch.lms_batch.has_permission",
"LMS Program": "lms.lms.doctype.lms_program.lms_program.has_permission",
"LMS Certificate": "lms.lms.doctype.lms_certificate.lms_certificate.has_permission",
}
# DocType Class # DocType Class
# --------------- # ---------------
@@ -130,7 +130,7 @@
} }
], ],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2025-12-02 16:58:49.903274", "modified": "2026-02-19 14:26:14.027340",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "Job", "module": "Job",
"name": "Job Opportunity", "name": "Job Opportunity",
@@ -149,24 +149,16 @@
"share": 1, "share": 1,
"write": 1 "write": 1
}, },
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"select": 1,
"share": 1
},
{ {
"create": 1, "create": 1,
"email": 1, "email": 1,
"export": 1, "export": 1,
"if_owner": 1, "if_owner": 1,
"print": 1, "print": 1,
"read": 1,
"report": 1, "report": 1,
"role": "LMS Student", "role": "LMS Student",
"select": 1,
"share": 1, "share": 1,
"write": 1 "write": 1
} }
@@ -1,7 +0,0 @@
// Copyright (c) 2022, Frappe and contributors
// For license information, please see license.txt
frappe.ui.form.on("Job Settings", {
// refresh: function(frm) {
// }
});
@@ -1,54 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2022-02-07 12:01:41.422955",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"allow_posting",
"title",
"subtitle"
],
"fields": [
{
"default": "0",
"fieldname": "allow_posting",
"fieldtype": "Check",
"label": "Allow Job Posting From Website"
},
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Job Board Title"
},
{
"fieldname": "subtitle",
"fieldtype": "Data",
"label": "Job Board Subtitle"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2022-02-11 15:56:38.958317",
"modified_by": "Administrator",
"module": "Job",
"name": "Job Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
@@ -1,9 +0,0 @@
# Copyright (c) 2022, Frappe and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class JobSettings(Document):
pass
@@ -1,9 +0,0 @@
# Copyright (c) 2022, Frappe and Contributors
# See license.txt
# import frappe
import unittest
class TestJobSettings(unittest.TestCase):
pass
+27 -9
View File
@@ -31,6 +31,7 @@ from pypika import functions as fn
from lms.lms.doctype.course_lesson.course_lesson import save_progress from lms.lms.doctype.course_lesson.course_lesson import save_progress
from lms.lms.utils import ( from lms.lms.utils import (
LMS_ROLES,
can_modify_batch, can_modify_batch,
can_modify_course, can_modify_course,
get_average_rating, get_average_rating,
@@ -41,6 +42,7 @@ from lms.lms.utils import (
get_lms_route, get_lms_route,
has_course_instructor_role, has_course_instructor_role,
has_evaluator_role, has_evaluator_role,
has_lms_role,
has_moderator_role, has_moderator_role,
) )
@@ -606,12 +608,7 @@ def check_app_permission():
if frappe.session.user == "Administrator": if frappe.session.user == "Administrator":
return True return True
roles = frappe.get_roles() return has_lms_role()
lms_roles = ["Moderator", "Course Creator", "Batch Evaluator", "LMS Student"]
if any(role in roles for role in lms_roles):
return True
return False
@frappe.whitelist() @frappe.whitelist()
@@ -1296,6 +1293,7 @@ def get_lms_settings():
"contact_us_url", "contact_us_url",
"livecode_url", "livecode_url",
"disable_pwa", "disable_pwa",
"allow_job_posting",
] ]
settings = frappe._dict() settings = frappe._dict()
@@ -1308,7 +1306,6 @@ def get_lms_settings():
@frappe.whitelist() @frappe.whitelist()
def cancel_evaluation(evaluation: dict): def cancel_evaluation(evaluation: dict):
evaluation = frappe._dict(evaluation) evaluation = frappe._dict(evaluation)
print(evaluation.member, frappe.session.user)
if evaluation.member != frappe.session.user: if evaluation.member != frappe.session.user:
frappe.throw(_("You do not have permission to cancel this evaluation."), frappe.PermissionError) frappe.throw(_("You do not have permission to cancel this evaluation."), frappe.PermissionError)
@@ -1369,6 +1366,9 @@ def get_certification_details(course: str):
@frappe.whitelist() @frappe.whitelist()
def save_role(user: str, role: str, value: int): def save_role(user: str, role: str, value: int):
frappe.only_for("Moderator") frappe.only_for("Moderator")
if role not in LMS_ROLES:
frappe.throw(_("You do not have permission to modify this role."), frappe.PermissionError)
if cint(value): if cint(value):
doc = frappe.get_doc( doc = frappe.get_doc(
{ {
@@ -1716,8 +1716,12 @@ def get_profile_details(username: str):
], ],
as_dict=True, as_dict=True,
) )
roles = frappe.get_roles(details.name)
details.roles = frappe.get_roles(details.name) if not has_lms_role():
frappe.throw(
_("User does not have permission to access this user's profile details."), frappe.PermissionError
)
details.roles = roles
return details return details
@@ -2204,3 +2208,17 @@ def get_assessment_from_lesson(course: str, assessmentType: str):
assessments.append(quiz_name) assessments.append(quiz_name)
return assessments return assessments
@frappe.whitelist()
def get_badges(member: str):
if not has_lms_role():
frappe.throw(_("You do not have permission to access badges."), frappe.PermissionError)
badges = frappe.get_all(
"LMS Badge Assignment",
{"member": member},
["name", "member", "badge", "badge_image", "badge_description", "issued_on"],
)
return badges
+6 -4
View File
@@ -5,7 +5,7 @@ frappe.ui.form.on("LMS Badge", {
refresh: (frm) => { refresh: (frm) => {
frm.events.set_field_options(frm); frm.events.set_field_options(frm);
if (frm.doc.event == "Auto Assign") { if (frm.doc.event == "Manual Assignment" && frm.doc.enabled) {
add_assign_button(frm); add_assign_button(frm);
} }
}, },
@@ -49,11 +49,13 @@ const add_assign_button = (frm) => {
frappe.call({ frappe.call({
method: "lms.lms.doctype.lms_badge.lms_badge.assign_badge", method: "lms.lms.doctype.lms_badge.lms_badge.assign_badge",
args: { args: {
badge: frm.doc, badge_name: frm.doc.name,
}, },
callback: function (r) { callback: function (r) {
if (r.message) { if (r.message == "success") {
frappe.msgprint(r.message); frappe.toast(__("Badge assigned successfully"));
} else {
frappe.toast(__("Failed to assign badge"));
} }
}, },
}); });
+3 -12
View File
@@ -52,14 +52,14 @@
"fieldtype": "Select", "fieldtype": "Select",
"in_list_view": 1, "in_list_view": 1,
"label": "Event", "label": "Event",
"options": "New\nValue Change\nAuto Assign", "options": "New\nValue Change\nManual Assignment",
"reqd": 1 "reqd": 1
}, },
{ {
"fieldname": "condition", "fieldname": "condition",
"fieldtype": "Code", "fieldtype": "Code",
"label": "Condition", "label": "Condition",
"mandatory_depends_on": "eval:doc.event == \"Auto Assign\"" "reqd": 1
}, },
{ {
"depends_on": "eval:doc.event == 'Value Change'", "depends_on": "eval:doc.event == 'Value Change'",
@@ -100,7 +100,7 @@
"link_fieldname": "badge" "link_fieldname": "badge"
} }
], ],
"modified": "2026-02-03 10:52:37.122370", "modified": "2026-02-20 17:58:25.924109",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Badge", "name": "LMS Badge",
@@ -131,15 +131,6 @@
"role": "Moderator", "role": "Moderator",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
} }
], ],
"row_format": "Dynamic", "row_format": "Dynamic",
+22 -7
View File
@@ -10,7 +10,7 @@ from frappe.model.document import Document
class LMSBadge(Document): class LMSBadge(Document):
def on_update(self): def on_update(self):
if self.event == "Auto Assign" and self.condition: if self.event == "Manual Assignment" and self.condition:
try: try:
json.loads(self.condition) json.loads(self.condition)
except ValueError: except ValueError:
@@ -54,6 +54,7 @@ def award(doc, member):
} }
) )
assignment.save() assignment.save()
return assignment.name
def eval_condition(doc, condition): def eval_condition(doc, condition):
@@ -61,16 +62,30 @@ def eval_condition(doc, condition):
@frappe.whitelist() @frappe.whitelist()
def assign_badge(badge: str, user: str): def assign_badge(badge_name: str):
badge = frappe._dict(json.loads(badge)) assignments = []
if not badge.event == "Auto Assign": badge = frappe.db.get_value(
"LMS Badge",
badge_name,
["name", "event", "reference_doctype", "condition", "user_field"],
as_dict=True,
)
if not badge:
frappe.throw(_("Badge {0} not found").format(badge_name), frappe.DoesNotExistError)
if not badge.event == "Manual Assignment":
return return
fields = ["name"] fields = ["name"]
fields.append(badge.user_field) fields.append(badge.user_field)
list = frappe.get_all(badge.reference_doctype, filters=badge.condition, fields=fields) docs = frappe.get_all(badge.reference_doctype, filters=json.loads(badge.condition), fields=fields)
for doc in list:
award(badge, doc.get(badge.user_field)) for doc in docs:
assignment_name = award(badge, doc.get(badge.user_field))
if assignment_name:
assignments.append(assignment_name)
return "success" if assignments else "failed"
def process_badges(doc, state): def process_badges(doc, state):
@@ -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-12-04 17:06:26.090276", "modified": "2026-02-19 15:06:08.389081",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Badge Assignment", "name": "LMS Badge Assignment",
@@ -120,10 +120,6 @@
"read": 1, "read": 1,
"role": "LMS Student" "role": "LMS Student"
}, },
{
"read": 1,
"role": "LMS Student"
},
{ {
"create": 1, "create": 1,
"delete": 1, "delete": 1,
@@ -10,17 +10,17 @@ from lms.lms.doctype.lms_badge.lms_badge import eval_condition
class LMSBadgeAssignment(Document): class LMSBadgeAssignment(Document):
def validate(self): def validate(self):
self.validate_owner()
self.validate_duplicate_badge_assignment() self.validate_duplicate_badge_assignment()
self.validate_badge_criteria() self.validate_badge_criteria()
self.validate_owner()
def validate_owner(self): def validate_owner(self):
if self.owner == self.member: event = frappe.db.get_value("LMS Badge", self.badge, "event")
return if event == "Manual Assignment":
roles = frappe.get_roles(frappe.session.user)
roles = frappe.get_roles(self.owner) admins = ["Moderator", "Course Creator", "Batch Evaluator"]
if "Moderator" not in roles: if not any(role in roles for role in admins):
frappe.throw(_("You must be a Moderator to assign badges to users.")) frappe.throw(_("You must be an Admin to assign badges to users."))
def validate_duplicate_badge_assignment(self): def validate_duplicate_badge_assignment(self):
grant_only_once = frappe.db.get_value("LMS Badge", self.badge, "grant_only_once") grant_only_once = frappe.db.get_value("LMS Badge", self.badge, "grant_only_once")
@@ -40,25 +40,27 @@ class LMSBadgeAssignment(Document):
"LMS Badge", self.badge, ["reference_doctype", "user_field", "condition", "enabled"], as_dict=True "LMS Badge", self.badge, ["reference_doctype", "user_field", "condition", "enabled"], as_dict=True
) )
if badge_details: if not badge_details:
if badge_details.reference_doctype and badge_details.user_field and badge_details.condition: return
user_fieldname = frappe.db.get_value(
"DocField", if badge_details.reference_doctype and badge_details.user_field and badge_details.condition:
{"parent": badge_details.reference_doctype, "fieldname": badge_details.user_field}, user_fieldname = frappe.db.get_value(
"fieldname", "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
documents = frappe.get_all( frappe.throw(_("Member does not meet the criteria for the badge {0}.").format(self.badge))
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))
+28
View File
@@ -20,6 +20,7 @@ from lms.lms.utils import (
get_lesson_url, get_lesson_url,
get_lms_route, get_lms_route,
get_quiz_details, get_quiz_details,
guest_access_allowed,
update_payment_record, update_payment_record,
) )
@@ -213,6 +214,10 @@ def create_live_class(
auto_recording: str, auto_recording: str,
description: str = None, description: str = None,
): ):
roles = frappe.get_roles()
if not any(role in roles for role in ["Moderator", "Batch Evaluator"]):
frappe.throw(_("You do not have permission to create a live class."))
payload = { payload = {
"topic": title, "topic": title,
"start_time": format_datetime(f"{date} {time}", "yyyy-MM-ddTHH:mm:ssZ"), "start_time": format_datetime(f"{date} {time}", "yyyy-MM-ddTHH:mm:ssZ"),
@@ -391,3 +396,26 @@ def send_mail(batch, student):
args=args, args=args,
header=[_(f"Batch Start Reminder: {batch.title}"), "orange"], header=[_(f"Batch Start Reminder: {batch.title}"), "orange"],
) )
def has_permission(doc, ptype="read", user=None):
user = user or frappe.session.user
if user == "Guest" and not guest_access_allowed():
return False
roles = frappe.get_roles(user)
if "Moderator" in roles or "Batch Evaluator" in roles:
return True
if ptype not in ("read", "select", "print"):
return False
is_enrolled = frappe.db.exists("LMS Batch Enrollment", {"batch": doc.name, "member": user})
if is_enrolled:
return True
is_batch_published = frappe.db.get_value("LMS Batch", doc.name, "published")
if is_batch_published:
return True
return False
@@ -123,7 +123,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-12-17 16:50:31.128747", "modified": "2026-02-20 17:32:34.580862",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Certificate", "name": "LMS Certificate",
@@ -153,27 +153,6 @@
"share": 1, "share": 1,
"write": 1 "write": 1
}, },
{
"create": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
},
{ {
"create": 1, "create": 1,
"delete": 1, "delete": 1,
@@ -197,6 +176,15 @@
"role": "Course Creator", "role": "Course Creator",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
} }
], ],
"row_format": "Dynamic", "row_format": "Dynamic",
@@ -12,6 +12,7 @@ from frappe.utils.telemetry import capture
class LMSCertificate(Document): class LMSCertificate(Document):
def validate(self): def validate(self):
self.validate_criteria()
self.validate_duplicate_certificate() self.validate_duplicate_certificate()
def autoname(self): def autoname(self):
@@ -54,6 +55,43 @@ class LMSCertificate(Document):
header=[subject, "green"], header=[subject, "green"],
) )
def validate_criteria(self):
self.validate_role_of_owner()
self.validate_batch_enrollment()
self.validate_course_enrollment()
def validate_role_of_owner(self):
roles = frappe.get_roles()
is_admin = any(role in roles for role in ["Moderator", "Course Creator", "Batch Evaluator"])
if not self.course and not self.batch_name and not is_admin:
frappe.throw(_("Course or Batch is required to issue a certificate."))
def validate_batch_enrollment(self):
if self.batch_name:
is_enrolled = frappe.db.exists(
"LMS Batch Enrollment", {"batch": self.batch_name, "member": self.member}
)
if not is_enrolled:
frappe.throw(_("Certification cannot be issued as the member is not enrolled in this batch."))
def validate_course_enrollment(self):
if self.course:
is_enrolled = frappe.db.exists("LMS Enrollment", {"course": self.course, "member": self.member})
if not is_enrolled:
frappe.throw(
_("Certification cannot be issued as the member is not enrolled in this course.")
)
completion_certificate = frappe.db.get_value("LMS Course", self.course, "enable_certification")
if completion_certificate:
progress = frappe.db.get_value(
"LMS Enrollment", {"course": self.course, "member": self.member}, "progress"
)
if progress < 100:
frappe.throw(
_("Certification cannot be issued as the member has not completed the course.")
)
def validate_duplicate_certificate(self): def validate_duplicate_certificate(self):
self.validate_course_duplicates() self.validate_course_duplicates()
self.validate_batch_duplicates() self.validate_batch_duplicates()
@@ -177,3 +215,23 @@ def validate_certification_eligibility(course):
) )
if progress < 100: if progress < 100:
frappe.throw(_("You have not completed the course yet.")) frappe.throw(_("You have not completed the course yet."))
def has_permission(doc, ptype="read", user=None):
user = user or frappe.session.user
roles = frappe.get_roles(user)
if "Moderator" in roles or "Course Creator" in roles or "Batch Evaluator" in roles:
return True
if doc.owner == user:
return True
if ptype not in ("read", "select", "print"):
return False
return doc.published
def get_permission_query_conditions(user):
user = user or frappe.session.user
roles = frappe.get_roles(user)
if "Moderator" in roles or "Course Creator" in roles or "Batch Evaluator" in roles:
return None
return """(`tabLMS Certificate`.published = 1)"""
@@ -157,7 +157,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:40:50.679211", "modified": "2026-02-19 16:01:43.810407",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Certificate Request", "name": "LMS Certificate Request",
@@ -192,6 +192,7 @@
"create": 1, "create": 1,
"email": 1, "email": 1,
"export": 1, "export": 1,
"if_owner": 1,
"print": 1, "print": 1,
"read": 1, "read": 1,
"report": 1, "report": 1,
@@ -41,7 +41,7 @@
"grid_page_length": 50, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2026-01-29 16:10:47.787285", "modified": "2026-02-20 17:40:39.823017",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Course Review", "name": "LMS Course Review",
@@ -63,8 +63,8 @@
"create": 1, "create": 1,
"email": 1, "email": 1,
"export": 1, "export": 1,
"if_owner": 1,
"print": 1, "print": 1,
"read": 1,
"report": 1, "report": 1,
"role": "LMS Student", "role": "LMS Student",
"share": 1, "share": 1,
@@ -8,7 +8,7 @@ from frappe.utils import ceil
class LMSEnrollment(Document): class LMSEnrollment(Document):
def validate(self): def before_insert(self):
self.validate_duplicate_enrollment() self.validate_duplicate_enrollment()
self.validate_course_enrollment_eligibility() self.validate_course_enrollment_eligibility()
self.validate_owner() self.validate_owner()
@@ -27,6 +27,7 @@ class LMSEnrollment(Document):
{ {
"course": self.course, "course": self.course,
"member": self.member, "member": self.member,
"name": ["!=", self.name],
}, },
) )
@@ -49,7 +50,10 @@ class LMSEnrollment(Document):
) )
if self.enrollment_from_batch: if self.enrollment_from_batch:
return if frappe.db.exists(
"LMS Batch Enrollment", {"batch": self.enrollment_from_batch, "member": self.member}
):
return
if not course_details.published and not is_admin(): if not course_details.published and not is_admin():
frappe.throw(_("You cannot enroll in an unpublished course.")) frappe.throw(_("You cannot enroll in an unpublished course."))
@@ -169,3 +169,18 @@ def get_minutes(duration_in_seconds):
if duration_in_seconds: if duration_in_seconds:
return int(duration_in_seconds) // 60 return int(duration_in_seconds) // 60
return 0 return 0
def has_permission(doc, ptype="read", user=None):
user = user or frappe.session.user
roles = frappe.get_roles(user)
if "Moderator" in roles or "Batch Evaluator" in roles:
return True
if ptype not in ("read", "select", "print"):
return False
return frappe.db.exists(
"LMS Batch Enrollment",
{"batch": doc.batch_name, "member": user},
)
@@ -5,6 +5,8 @@ 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 guest_access_allowed
class LMSProgram(Document): class LMSProgram(Document):
def validate(self): def validate(self):
@@ -41,3 +43,27 @@ class LMSProgram(Document):
if self.member_count != member_count: if self.member_count != member_count:
self.member_count = member_count self.member_count = member_count
def has_permission(doc, ptype="read", user=None):
user = user or frappe.session.user
if user == "Guest" and not guest_access_allowed():
return False
roles = frappe.get_roles(user)
if "Moderator" in roles or "Course Creator" in roles:
return True
if ptype not in ("read", "select", "print"):
return False
is_enrolled = frappe.db.exists("LMS Program Member", {"parent": doc.name, "member": user})
if is_enrolled:
return True
is_program_published = frappe.db.get_value("LMS Program", doc.name, "published")
if is_program_published:
return True
return False
@@ -88,7 +88,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-06-24 14:42:08.288983", "modified": "2026-02-20 14:43:56.587110",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Programming Exercise Submission", "name": "LMS Programming Exercise Submission",
@@ -146,6 +146,7 @@
"create": 1, "create": 1,
"email": 1, "email": 1,
"export": 1, "export": 1,
"if_owner": 1,
"print": 1, "print": 1,
"read": 1, "read": 1,
"report": 1, "report": 1,
@@ -113,11 +113,12 @@
"read_only": 1 "read_only": 1
} }
], ],
"grid_page_length": 50,
"in_create": 1, "in_create": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-10-07 16:52:04.162521", "modified": "2026-02-19 16:31:23.401819",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Quiz Submission", "name": "LMS Quiz Submission",
"owner": "Administrator", "owner": "Administrator",
@@ -133,21 +134,12 @@
"role": "System Manager", "role": "System Manager",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"title_field": "member_name", "title_field": "member_name",
"track_changes": 1 "track_changes": 1
} }
+15 -9
View File
@@ -76,7 +76,9 @@
"contact_us_tab", "contact_us_tab",
"contact_us_email", "contact_us_email",
"column_break_gcgv", "column_break_gcgv",
"contact_us_url" "contact_us_url",
"jobs_tab",
"allow_job_posting"
], ],
"fields": [ "fields": [
{ {
@@ -471,13 +473,24 @@
{ {
"fieldname": "column_break_dtns", "fieldname": "column_break_dtns",
"fieldtype": "Column Break" "fieldtype": "Column Break"
},
{
"fieldname": "jobs_tab",
"fieldtype": "Tab Break",
"label": "Jobs"
},
{
"default": "1",
"fieldname": "allow_job_posting",
"fieldtype": "Check",
"label": "Allow Job Posting"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2026-01-01 19:36:54.443390", "modified": "2026-02-19 16:28:15.310145",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Settings", "name": "LMS Settings",
@@ -493,13 +506,6 @@
"share": 1, "share": 1,
"write": 1 "write": 1
}, },
{
"email": 1,
"print": 1,
"read": 1,
"role": "LMS Student",
"share": 1
},
{ {
"create": 1, "create": 1,
"delete": 1, "delete": 1,
@@ -102,7 +102,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-07-30 14:38:52.555010", "modified": "2026-02-20 12:02:29.458645",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Video Watch Duration", "name": "LMS Video Watch Duration",
@@ -120,17 +120,6 @@
"share": 1, "share": 1,
"write": 1 "write": 1
}, },
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1,
"write": 1
},
{ {
"create": 1, "create": 1,
"delete": 1, "delete": 1,
@@ -154,6 +143,18 @@
"role": "Course Creator", "role": "Course Creator",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1,
"write": 1
} }
], ],
"row_format": "Dynamic", "row_format": "Dynamic",
+15
View File
@@ -31,6 +31,7 @@ from pypika import functions as fn
from lms.lms.md import find_macros from lms.lms.md import find_macros
RE_SLUG_NOTALLOWED = re.compile("[^a-z0-9]+") RE_SLUG_NOTALLOWED = re.compile("[^a-z0-9]+")
LMS_ROLES = ["Moderator", "Course Creator", "Batch Evaluator", "LMS Student"]
def get_lms_path(): def get_lms_path():
@@ -1209,6 +1210,9 @@ def get_country_code():
@frappe.whitelist() @frappe.whitelist()
def get_question_details(question: str) -> dict: def get_question_details(question: str) -> dict:
if not has_lms_role():
frappe.throw(_("You are not authorized to view the question details."))
fields = ["question", "type", "multiple"] fields = ["question", "type", "multiple"]
for i in range(1, 5): for i in range(1, 5):
fields.append(f"option_{i}") fields.append(f"option_{i}")
@@ -1239,6 +1243,10 @@ def get_batch_courses(batch: str) -> list:
@frappe.whitelist() @frappe.whitelist()
def get_assessments(batch: str) -> list: def get_assessments(batch: str) -> list:
member = frappe.session.user member = frappe.session.user
is_enrolled = frappe.db.exists("LMS Batch Enrollment", {"batch": batch, "member": member})
if not is_enrolled and not can_modify_batch(batch):
frappe.throw(_("You are not authorized to view the assessments of this batch."))
assessments = frappe.get_all( assessments = frappe.get_all(
"LMS Assessment", "LMS Assessment",
{"parent": batch}, {"parent": batch},
@@ -2297,3 +2305,10 @@ def can_modify_batch(batch: str) -> bool:
if not (has_moderator_role() or is_instructor): if not (has_moderator_role() or is_instructor):
return False return False
return True return True
def has_lms_role():
roles = frappe.get_roles()
lms_roles = set(LMS_ROLES)
user_roles = set(roles)
return not lms_roles.isdisjoint(user_roles)
+3 -1
View File
@@ -117,4 +117,6 @@ lms.patches.v2_0.fix_job_application_resume_urls
lms.patches.v2_0.open_to_opportunities lms.patches.v2_0.open_to_opportunities
lms.patches.v2_0.open_to_work lms.patches.v2_0.open_to_work
lms.patches.v2_0.share_enrollment lms.patches.v2_0.share_enrollment
lms.patches.v2_0.give_user_list_permission #11-02-2026 lms.patches.v2_0.give_user_list_permission #11-02-2026
lms.patches.v2_0.rename_badge_assignment_event
lms.patches.v2_0.enable_allow_job_posting
@@ -0,0 +1,5 @@
import frappe
def execute():
frappe.db.set_single_value("LMS Settings", "allow_job_posting", 1)
@@ -0,0 +1,7 @@
import frappe
def execute():
badge_with_auto_assign = frappe.get_all("LMS Badge", filters={"event": "Auto Assign"}, fields=["name"])
for badge in badge_with_auto_assign:
frappe.db.set_value("LMS Badge", badge.name, "event", "Manual Assignment")